feat(editor): selectable LF/CRLF line endings in code viewer (#3009)

Files saved from the built-in code editor always came out CRLF, which
breaks scripts downloaded to Linux. The editor settings menu now offers
a Line ending choice (LF/CRLF) applied via model.setEOL; the choice is
detected from the file's existing endings on load, and the saved payload
is normalized to the selected sequence.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent c87f3015de
commit 9c70c554cb

@ -468,7 +468,8 @@
"noEncryption": "Not encrypted", "noEncryption": "Not encrypted",
"ftsStatus": "Full-text index", "ftsStatus": "Full-text index",
"ftsIndexed": "Indexed", "ftsIndexed": "Indexed",
"ftsNotIndexed": "Not indexed" "ftsNotIndexed": "Not indexed",
"lineEnding": "Line ending"
}, },
"modals": { "modals": {
"includePasswordInShareLink": "Include password in share link", "includePasswordInShareLink": "Include password in share link",

@ -468,7 +468,8 @@
"noEncryption": "未加密", "noEncryption": "未加密",
"ftsStatus": "全文索引", "ftsStatus": "全文索引",
"ftsIndexed": "已索引", "ftsIndexed": "已索引",
"ftsNotIndexed": "未索引" "ftsNotIndexed": "未索引",
"lineEnding": "换行符"
}, },
"modals": { "modals": {
"includePasswordInShareLink": "在链接中包含密码", "includePasswordInShareLink": "在链接中包含密码",

@ -108,6 +108,7 @@ const CodeViewer = () => {
const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null); const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null);
const [language, setLng] = useState<string | null>(null); const [language, setLng] = useState<string | null>(null);
const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off"); const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off");
const [eol, setEol] = useState<"LF" | "CRLF">("LF");
const saveFunction = useRef<() => void>(() => {}); const saveFunction = useRef<() => void>(() => {});
const closeViewer = useCallback(() => { const closeViewer = useCallback(() => {
@ -141,7 +142,11 @@ const CodeViewer = () => {
setOptionAnchorEl(null); setOptionAnchorEl(null);
dispatch(getEntityContent(viewerState.file, viewerState.version)) dispatch(getEntityContent(viewerState.file, viewerState.version))
.then((res) => { .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); setLoaded(true);
}) })
.catch(() => { .catch(() => {
@ -188,7 +193,8 @@ const CodeViewer = () => {
} }
setLoading(true); 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(() => { .then(() => {
setSaved(true); setSaved(true);
}) })
@ -196,7 +202,7 @@ const CodeViewer = () => {
setLoading(false); setLoading(false);
}); });
}, },
[value, viewerState], [value, viewerState, eol],
); );
const onChange = useCallback((v: string) => { const onChange = useCallback((v: string) => {
@ -298,6 +304,18 @@ const CodeViewer = () => {
))} ))}
</CascadingSubmenu> </CascadingSubmenu>
<DenseDivider /> <DenseDivider />
<CascadingSubmenu popupId={"eol"} title={t("fileManager.lineEnding")}>
{(["LF", "CRLF"] as const).map((e) => (
<SquareMenuItem key={e} onClick={() => setEol(e)} dense>
<ListItemText>{e}</ListItemText>
{e == eol && (
<ListItemIcon>
<Checkmark />
</ListItemIcon>
)}
</SquareMenuItem>
))}
</CascadingSubmenu>
<SquareMenuItem onClick={toggleWordWrap} dense> <SquareMenuItem onClick={toggleWordWrap} dense>
<ListItemText>{t("fileManager.wordWrap")}</ListItemText> <ListItemText>{t("fileManager.wordWrap")}</ListItemText>
{wordWrap === "on" && ( {wordWrap === "on" && (
@ -327,6 +345,7 @@ const CodeViewer = () => {
}} }}
value={value} value={value}
language={language ?? ""} language={language ?? ""}
eol={eol}
onChange={(v) => onChange(v as string)} onChange={(v) => onChange(v as string)}
/> />
</Box> </Box>

@ -107,6 +107,11 @@ export interface MonacoEditorProps extends MonacoEditorBaseProps {
minHeight?: string | number; minHeight?: string | number;
onSave?: React.MutableRefObject<() => void>; onSave?: React.MutableRefObject<() => void>;
/**
* Desired end-of-line sequence applied to the model via setEOL.
*/
eol?: "LF" | "CRLF";
} }
// ============ Diff Editor ============ // ============ Diff Editor ============
@ -206,6 +211,7 @@ function MonacoEditor({
className, className,
uri, uri,
onSave, onSave,
eol,
}: MonacoEditorProps) { }: MonacoEditorProps) {
const containerElement = useRef<HTMLDivElement | null>(null); const containerElement = useRef<HTMLDivElement | null>(null);
@ -322,6 +328,13 @@ function MonacoEditor({
} }
}, [language]); }, [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(() => { useEffect(() => {
if (editor.current) { if (editor.current) {
// Don't pass in the model on update because monaco crashes if we pass the model // Don't pass in the model on update because monaco crashes if we pass the model

Loading…
Cancel
Save