From dece1c7098de2efe38aaa25d6cafc41a2de568ff Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Fri, 12 Sep 2025 15:38:49 +0800 Subject: [PATCH] feat(archive viewer): option to select text encoding for zip files --- src/api/explorer.ts | 1 + .../Common/Form/EncodingSelector.tsx | 127 ++++++ .../FileManager/Dialogs/ExtractArchive.tsx | 94 +---- .../CustomProps/MultiSelectPropsContent.tsx | 2 +- .../Viewers/ArchivePreview/ArchivePreview.tsx | 383 ++++++++++-------- src/redux/globalStateSlice.ts | 5 +- 6 files changed, 359 insertions(+), 253 deletions(-) create mode 100644 src/component/Common/Form/EncodingSelector.tsx diff --git a/src/api/explorer.ts b/src/api/explorer.ts index 4a471ad..9da39aa 100644 --- a/src/api/explorer.ts +++ b/src/api/explorer.ts @@ -564,4 +564,5 @@ export interface ArchiveListFilesResponse { export interface ArchiveListFilesService { uri: string; entity?: string; + text_encoding?: string; } diff --git a/src/component/Common/Form/EncodingSelector.tsx b/src/component/Common/Form/EncodingSelector.tsx new file mode 100644 index 0000000..08bccfd --- /dev/null +++ b/src/component/Common/Form/EncodingSelector.tsx @@ -0,0 +1,127 @@ +import { + FormControl, + InputAdornment, + InputLabel, + MenuItem, + Select, + SelectProps, + styled, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { NoLabelFilledSelect } from "../../FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx"; +import Translate from "../../Icons/Translate.tsx"; + +const encodings = [ + "ibm866", + "iso8859_2", + "iso8859_3", + "iso8859_4", + "iso8859_5", + "iso8859_6", + "iso8859_7", + "iso8859_8", + "iso8859_8I", + "iso8859_10", + "iso8859_13", + "iso8859_14", + "iso8859_15", + "iso8859_16", + "koi8r", + "koi8u", + "macintosh", + "windows874", + "windows1250", + "windows1251", + "windows1252", + "windows1253", + "windows1254", + "windows1255", + "windows1256", + "windows1257", + "windows1258", + "macintoshcyrillic", + "gbk", + "gb18030", + "big5", + "eucjp", + "iso2022jp", + "shiftjis", + "euckr", + "utf16be", + "utf16le", +]; + +const defaultEncodingValue = " "; + +export interface EncodingSelectorProps { + value: string; + onChange: (value: string) => void; + label?: string; + size?: "small" | "medium"; + variant?: "outlined" | "standard" | "filled"; + fullWidth?: boolean; + showIcon?: boolean; + SelectProps?: Partial; +} + +export const StyledInputAdornment = styled(InputAdornment)(({ theme }) => ({ + "&.MuiInputAdornment-positionStart": { + marginTop: "0!important", + }, +})); + +const EncodingSelector = ({ + value, + onChange, + label, + size = "medium", + variant = "outlined", + fullWidth = false, + showIcon = true, + SelectProps, +}: EncodingSelectorProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const displayLabel = label || t("modals.selectEncoding"); + + const SelectComponent = size == "small" ? NoLabelFilledSelect : Select; + const InputAdornmentComponent = size == "small" ? StyledInputAdornment : InputAdornment; + + return ( + + {size != "small" && {displayLabel}} + + + + ) + } + label={displayLabel} + value={value} + onChange={(e) => onChange(e.target.value as string)} + {...SelectProps} + > + + {t("modals.defaultEncoding")} + + {encodings.map((enc) => ( + + {enc} + + ))} + + + ); +}; + +export { defaultEncodingValue }; +export default EncodingSelector; diff --git a/src/component/FileManager/Dialogs/ExtractArchive.tsx b/src/component/FileManager/Dialogs/ExtractArchive.tsx index d1d6d58..286c1ba 100644 --- a/src/component/FileManager/Dialogs/ExtractArchive.tsx +++ b/src/component/FileManager/Dialogs/ExtractArchive.tsx @@ -1,15 +1,4 @@ -import { - DialogContent, - FormControl, - Grid2, - InputAdornment, - InputLabel, - MenuItem, - Select, - TextField, - useMediaQuery, - useTheme, -} from "@mui/material"; +import { DialogContent, Grid2, InputAdornment, TextField, useMediaQuery, useTheme } from "@mui/material"; import { useSnackbar } from "notistack"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -17,56 +6,14 @@ import { sendExtractArchive } from "../../../api/api.ts"; import { closeExtractArchiveDialog } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { fileExtension, getFileLinkedUri } from "../../../util"; +import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx"; import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx"; import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; import Password from "../../Icons/Password.tsx"; -import Translate from "../../Icons/Translate.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; -const encodings = [ - "ibm866", - "iso8859_2", - "iso8859_3", - "iso8859_4", - "iso8859_5", - "iso8859_6", - "iso8859_7", - "iso8859_8", - "iso8859_8I", - "iso8859_10", - "iso8859_13", - "iso8859_14", - "iso8859_15", - "iso8859_16", - "koi8r", - "koi8u", - "macintosh", - "windows874", - "windows1250", - "windows1251", - "windows1252", - "windows1253", - "windows1254", - "windows1255", - "windows1256", - "windows1257", - "windows1258", - "macintoshcyrillic", - "gbk", - "gb18030", - "big5", - "eucjp", - "iso2022jp", - "shiftjis", - "euckr", - "utf16be", - "utf16le", -]; - -const defaultEncodingValue = " "; - const ExtractArchive = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -84,6 +31,11 @@ const ExtractArchive = () => { const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask); + const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding); + + useEffect(() => { + setEncoding(predefinedEncoding ?? defaultEncodingValue); + }, [predefinedEncoding]); const showEncodingOption = useMemo(() => { const ext = fileExtension(target?.name ?? ""); @@ -167,31 +119,13 @@ const ExtractArchive = () => { md: 6, }} > - - {t("modals.selectEncoding")} - - + )} ({ +export const NoLabelFilledSelect = styled(Select)(({ theme }) => ({ "& .MuiSelect-select": { paddingTop: theme.spacing(1), paddingBottom: theme.spacing(1), diff --git a/src/component/Viewers/ArchivePreview/ArchivePreview.tsx b/src/component/Viewers/ArchivePreview/ArchivePreview.tsx index c4b397a..e38fc0a 100644 --- a/src/component/Viewers/ArchivePreview/ArchivePreview.tsx +++ b/src/component/Viewers/ArchivePreview/ArchivePreview.tsx @@ -7,7 +7,9 @@ import { getArchiveListFiles } from "../../../api/api.ts"; import { ArchivedFile, FileType } from "../../../api/explorer.ts"; import { closeArchiveViewer, setExtractArchiveDialog } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; -import { fileBase, getFileLinkedUri, sizeToString } from "../../../util"; +import { fileBase, fileExtension, getFileLinkedUri, sizeToString } from "../../../util"; +import AutoHeight from "../../Common/AutoHeight.tsx"; +import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx"; import { SecondaryButton, StyledCheckbox, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx"; import TimeBadge from "../../Common/TimeBadge.tsx"; import FileIcon from "../../FileManager/Explorer/FileIcon.tsx"; @@ -29,6 +31,11 @@ const ArchivePreview = () => { const [selectedFiles, setSelectedFiles] = useState([]); const [filterText, setFilterText] = useState(""); const [height, setHeight] = useState(33); + const [encoding, setEncoding] = useState(defaultEncodingValue); + + const isZip = useMemo(() => { + return fileExtension(viewerState?.file?.name ?? "") === "zip"; + }, [viewerState?.file?.name]); const currentFiles = useMemo(() => { if (!files) return []; @@ -88,6 +95,7 @@ const ArchivePreview = () => { useEffect(() => { if (!viewerState || !viewerState.open) { + setEncoding(defaultEncodingValue); return; } @@ -101,6 +109,7 @@ const ArchivePreview = () => { getArchiveListFiles({ uri: getFileLinkedUri(viewerState.file), entity: viewerState.version, + text_encoding: encoding !== defaultEncodingValue ? encoding : undefined, }), ) .then((res) => { @@ -110,29 +119,33 @@ const ArchivePreview = () => { const allDirs = new Set(); // 目录项 - res.files.filter(item => item.is_directory).forEach(item => { - allDirs.add(item.name); - allItems.push(item); - }); + res.files + .filter((item) => item.is_directory) + .forEach((item) => { + allDirs.add(item.name); + allItems.push(item); + }); // 文件项,并补齐缺失目录 - res.files.filter(item => !item.is_directory).forEach(item => { - allItems.push(item); + res.files + .filter((item) => !item.is_directory) + .forEach((item) => { + allItems.push(item); - const dirElements = item.name.split("/"); - for (let i = 1; i < dirElements.length; i++) { - const dirName = dirElements.slice(0, i).join("/"); - if (!allDirs.has(dirName)) { - allDirs.add(dirName); - allItems.push({ - name: dirName, - size: 0, - updated_at: "1970-01-01T00:00:00Z", - is_directory: true, - }); + const dirElements = item.name.split("/"); + for (let i = 1; i < dirElements.length; i++) { + const dirName = dirElements.slice(0, i).join("/"); + if (!allDirs.has(dirName)) { + allDirs.add(dirName); + allItems.push({ + name: dirName, + size: 0, + updated_at: "1970-01-01T00:00:00Z", + is_directory: true, + }); + } } - } - }); + }); // 排序文件 // 先目录,后文件,分别按名称排序 @@ -151,7 +164,7 @@ const ArchivePreview = () => { .finally(() => { setLoading(false); }); - }, [viewerState]); + }, [viewerState, encoding]); const onClose = useCallback(() => { dispatch(closeArchiveViewer()); @@ -214,15 +227,15 @@ const ArchivePreview = () => { return; } - dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file, mask: selectedFiles })); - }, [selectedFiles, t, enqueueSnackbar]); + dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file, mask: selectedFiles, encoding })); + }, [selectedFiles, t, enqueueSnackbar, encoding]); const extractArchive = useCallback(() => { if (!viewerState?.file) { return; } - dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file })); - }, [viewerState?.file]); + dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file, encoding })); + }, [viewerState?.file, encoding]); return ( <> @@ -236,45 +249,18 @@ const ArchivePreview = () => { maxWidth: "lg", }} > - {loading && } - {!loading && ( - - - }> - navigateToBreadcrumb(-1)} - sx={{ - display: "flex", - alignItems: "center", - textDecoration: "none", - "&:hover": { textDecoration: "underline" }, - }} - > - - {t("fileManager.rootFolder")} - - {breadcrumbPaths.map((path, index) => { - const isLast = index === breadcrumbPaths.length - 1; - return isLast ? ( - - - {path} - - ) : ( + +
+ {loading && } + {!loading && ( + + + }> navigateToBreadcrumb(index)} + onClick={() => navigateToBreadcrumb(-1)} sx={{ display: "flex", alignItems: "center", @@ -282,125 +268,180 @@ const ArchivePreview = () => { "&:hover": { textDecoration: "underline" }, }} > - - {path} + + {t("fileManager.rootFolder")} - ); - })} - - - - {filteredFiles.length > 0 ? ( - - { - setHeight(h + 0.5); - }} - components={{ - // eslint-disable-next-line react/display-name - Table: (props) => , - }} - data={filteredFiles} - itemContent={(_index, file) => { - const fullPath = currentPath ? currentPath + "/" + file.name : file.name; - const isSelected = selectedFiles.includes(fullPath); - - return ( - <> - - toggleFileSelection(file.name)} - size="small" - /> - - { + const isLast = index === breadcrumbPaths.length - 1; + return isLast ? ( + + + {path} + + ) : ( + navigateToBreadcrumb(index)} sx={{ - minWidth: 300, - width: "100%", - padding: "4px 8px", + display: "flex", + alignItems: "center", + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, }} > - - - {file.is_directory ? ( - navigateToDirectory(file.name)} - sx={{ - color: "primary.main", - fontWeight: 500, - textDecoration: "none", - background: "none", - border: "none", - cursor: "pointer", - padding: 0, - "&:hover": { textDecoration: "underline" }, - }} - > - {fileBase(file.name)} - - ) : ( - - {fileBase(file.name)} - - )} - - - - - {file.is_directory ? "-" : sizeToString(file.size)} - - - - - {file.updated_at ? : "-"} - - - - ); - }} - /> - - ) : ( - - {t("fileManager.nothingFound")} - - )} + + {path} + + ); + })} + + - {!viewerState?.version && ( - - - {selectedFiles.length > 0 && ( - - {t("fileManager.extractSelected")} - + {filteredFiles.length > 0 ? ( + + { + setHeight(h + 0.5); + }} + components={{ + // eslint-disable-next-line react/display-name + Table: (props) =>
, + }} + data={filteredFiles} + itemContent={(_index, file) => { + const fullPath = currentPath ? currentPath + "/" + file.name : file.name; + const isSelected = selectedFiles.includes(fullPath); + + return ( + <> + + toggleFileSelection(file.name)} + size="small" + /> + + + + + {file.is_directory ? ( + navigateToDirectory(file.name)} + sx={{ + color: "primary.main", + fontWeight: 500, + textDecoration: "none", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + "&:hover": { textDecoration: "underline" }, + }} + > + {fileBase(file.name)} + + ) : ( + + {fileBase(file.name)} + + )} + + + + + {file.is_directory ? "-" : sizeToString(file.size)} + + + + + {file.updated_at ? : "-"} + + + + ); + }} + /> + + ) : ( + + {t("fileManager.nothingFound")} + + )} + + {!viewerState?.version && ( + + + + {selectedFiles.length > 0 && ( + + {t("fileManager.extractSelected")} + + )} + + {isZip && ( + + + + )} + )} )} - - )} + + ); diff --git a/src/redux/globalStateSlice.ts b/src/redux/globalStateSlice.ts index b0939d4..8fff021 100644 --- a/src/redux/globalStateSlice.ts +++ b/src/redux/globalStateSlice.ts @@ -179,6 +179,7 @@ export interface GlobalStateSlice { extractArchiveDialogOpen?: boolean; extractArchiveDialogFile?: FileResponse; extractArchiveDialogMask?: string[]; + extractArchiveDialogEncoding?: string; // Remote download dialog remoteDownloadDialogOpen?: boolean; @@ -381,15 +382,17 @@ export const globalStateSlice = createSlice({ }, setExtractArchiveDialog: ( state, - action: PayloadAction<{ open: boolean; file?: FileResponse; mask?: string[] }>, + action: PayloadAction<{ open: boolean; file?: FileResponse; mask?: string[]; encoding?: string }>, ) => { state.extractArchiveDialogOpen = action.payload.open; state.extractArchiveDialogFile = action.payload.file; state.extractArchiveDialogMask = action.payload.mask; + state.extractArchiveDialogEncoding = action.payload.encoding; }, closeExtractArchiveDialog: (state) => { state.extractArchiveDialogOpen = false; state.extractArchiveDialogMask = undefined; + state.extractArchiveDialogEncoding = undefined; }, setCreateArchiveDialog: ( state,