diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index d0af75d8..6ad33e84 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -468,7 +468,8 @@ "noEncryption": "Not encrypted", "ftsStatus": "Full-text index", "ftsIndexed": "Indexed", - "ftsNotIndexed": "Not indexed" + "ftsNotIndexed": "Not indexed", + "lineEnding": "Line ending" }, "modals": { "includePasswordInShareLink": "Include password in share link", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index b0ed6e19..25e5fedd 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -468,7 +468,8 @@ "noEncryption": "未加密", "ftsStatus": "全文索引", "ftsIndexed": "已索引", - "ftsNotIndexed": "未索引" + "ftsNotIndexed": "未索引", + "lineEnding": "换行符" }, "modals": { "includePasswordInShareLink": "在链接中包含密码", diff --git a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx index c45cb94d..377677fe 100644 --- a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx +++ b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx @@ -108,6 +108,7 @@ const CodeViewer = () => { const [optionAnchorEl, setOptionAnchorEl] = useState(null); const [language, setLng] = useState(null); const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off"); + const [eol, setEol] = useState<"LF" | "CRLF">("LF"); const saveFunction = useRef<() => void>(() => {}); const closeViewer = useCallback(() => { @@ -141,7 +142,11 @@ const CodeViewer = () => { setOptionAnchorEl(null); dispatch(getEntityContent(viewerState.file, viewerState.version)) .then((res) => { - setValue(new TextDecoder(charset).decode(res)); + const content = new TextDecoder(charset).decode(res); + setValue(content); + // Preserve the file's existing line endings as the default + // (#3009): CRLF files keep CRLF, everything else uses LF. + setEol(content.includes("\r\n") ? "CRLF" : "LF"); setLoaded(true); }) .catch(() => { @@ -188,7 +193,8 @@ const CodeViewer = () => { } setLoading(true); - dispatch(saveCode(value, viewerState.file, viewerState.version, saveAs)) + const eolChar = eol == "LF" ? "\n" : "\r\n"; + dispatch(saveCode(value.replace(/\r\n|\r|\n/g, eolChar), viewerState.file, viewerState.version, saveAs)) .then(() => { setSaved(true); }) @@ -196,7 +202,7 @@ const CodeViewer = () => { setLoading(false); }); }, - [value, viewerState], + [value, viewerState, eol], ); const onChange = useCallback((v: string) => { @@ -298,6 +304,18 @@ const CodeViewer = () => { ))} + + {(["LF", "CRLF"] as const).map((e) => ( + setEol(e)} dense> + {e} + {e == eol && ( + + + + )} + + ))} + {t("fileManager.wordWrap")} {wordWrap === "on" && ( @@ -327,6 +345,7 @@ const CodeViewer = () => { }} value={value} language={language ?? ""} + eol={eol} onChange={(v) => onChange(v as string)} /> diff --git a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx index b9496f62..cb9eb79d 100644 --- a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx +++ b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx @@ -107,6 +107,11 @@ export interface MonacoEditorProps extends MonacoEditorBaseProps { minHeight?: string | number; onSave?: React.MutableRefObject<() => void>; + + /** + * Desired end-of-line sequence applied to the model via setEOL. + */ + eol?: "LF" | "CRLF"; } // ============ Diff Editor ============ @@ -206,6 +211,7 @@ function MonacoEditor({ className, uri, onSave, + eol, }: MonacoEditorProps) { const containerElement = useRef(null); @@ -322,6 +328,13 @@ function MonacoEditor({ } }, [language]); + useEffect(() => { + if (editor.current && eol) { + const model = editor.current.getModel(); + model?.setEOL(eol == "LF" ? monaco.editor.EndOfLineSequence.LF : monaco.editor.EndOfLineSequence.CRLF); + } + }, [eol]); + useEffect(() => { if (editor.current) { // Don't pass in the model on update because monaco crashes if we pass the model