Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions ui/src/pages/compose/components/editor-common.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {MonacoEditor} from "./editor.tsx";
import {useEffect, useState} from "react";
import {useCallback, useEffect, useState} from "react";
import {useSnackbar} from "../../../hooks/snackbar.ts";
import {Alert, AlertTitle, Box, Button, CircularProgress, Link, Typography} from '@mui/material';
import {ErrorOutline, WarningAmber} from '@mui/icons-material';
import {type SaveState, useSaveStatus} from "../hooks/status-hook.tsx";
import {useEditorSave} from "../state/save.ts";
import {ErrFileNotSupported} from "../../../context/file-context.tsx";

interface TextEditorProps {
Expand All @@ -22,7 +23,20 @@ function EditorCommon({filename, setFileSaveStatus, saveFile, getFile}: TextEdit
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");

const {status, handleContentChange} = useSaveStatus(500, filename);
const registerSaver = useEditorSave(state => state.registerSaver);
const unregisterSaver = useEditorSave(state => state.unregisterSaver);

const saveContents = useCallback(async (newContent: string): Promise<SaveState> => {
const err = await saveFile(filename, newContent);
if (err) {
showError(`Could not save contents: ${err}`);
return 'error'
} else {
return 'success'
}
}, [filename, saveFile, showError]);

const {status, handleContentChange, saveNow, setBaseline} = useSaveStatus(500, filename, saveContents);

const refreshFile = async () => {
await getFile(filename)
Expand All @@ -33,7 +47,19 @@ function EditorCommon({filename, setFileSaveStatus, saveFile, getFile}: TextEdit
setLoading(true)

const {contents, err} = await getFile(filename)
if (err) {

if (!err) {
// the on-disk content is the baseline used to decide whether the
// file has unsaved changes (reverting edits clears "unsaved")
setBaseline(contents)
}

// prefer an in-memory draft (unsaved edits from before a tab switch)
// over the persisted content coming from the backend
const draft = useEditorSave.getState().drafts[filename];
if (draft !== undefined) {
setContents(draft)
} else if (err) {
setErr(err)
} else {
setContents(contents)
Expand All @@ -42,27 +68,51 @@ function EditorCommon({filename, setFileSaveStatus, saveFile, getFile}: TextEdit
setLoading(false);
};

const saveContents = async (newContent: string): Promise<SaveState> => {
const err = await saveFile(filename, newContent);
if (err) {
showError(`Could not save contents: ${err}`);
return 'error'
} else {
return 'success'
}
};

useEffect(() => {
setFileSaveStatus(status)
}, [status]);

useEffect(() => {
loadFile().then();
}, []);
// reload content when switching to a different file
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filename]);

// expose the manual save so toolbar buttons (diskette) can trigger it
useEffect(() => {
registerSaver(filename, saveNow);
return () => unregisterSaver(filename);
}, [filename, saveNow, registerSaver, unregisterSaver]);

// CTRL+S / CMD+S saves pending changes, even when auto-save is enabled
// (flushes the debounce immediately)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
if (!e.repeat) {
saveNow().then();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [saveNow]);

// warn before closing the browser tab with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (useEditorSave.getState().dirtyFiles[filename]) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [filename]);

const onContentChange = (value: string | undefined) => {
if (!value) return;
handleContentChange(value, saveContents)
if (value === undefined) return;
handleContentChange(value)
}

if (loading) {
Expand Down
93 changes: 48 additions & 45 deletions ui/src/pages/compose/components/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {Editor, type Monaco} from "@monaco-editor/react";
import {getLanguageFromExtension} from "../../../lib/editor";
import {useCallback, useEffect, useRef, useState} from "react";
import {useCallback, useEffect, useRef} from "react";
import * as monacoEditor from "monaco-editor";
import {callRPC, useHostClient} from "../../../lib/api.ts";
import {useSnackbar} from "../../../hooks/snackbar.ts";
import {useTabs, useTabsStore} from "../../../context/tab-context.tsx";
import {getContextKey, useTabs, useTabsStore} from "../../../context/tab-context.tsx";
import {FileService} from "../../../gen/files/v1/files_pb.ts";

interface MonacoEditorProps {
Expand All @@ -24,19 +24,48 @@ export function MonacoEditor(

const editorRef = useRef<monacoEditor.editor.IStandaloneCodeEditor | null>(null);
const saveLineNum = useSaveLineNum()

const [mounted, setMounted] = useState(false);
const {setTabDetails} = useTabs()

// keep the active filename in a ref: listeners are attached once on mount
// and mount does NOT re-run when the editor swaps to another file's model
const selectedFileRef = useRef(selectedFile);
selectedFileRef.current = selectedFile;

// Each file gets its own monaco model, unique per host/alias context.
// Combined with keepCurrentModel this makes undo/redo history and unsaved
// content survive switching between tabs (until a full page reload).
const modelPath = `${getContextKey()}/${selectedFile}`;

const restoreCaret = useCallback((editor: monacoEditor.editor.IStandaloneCodeEditor) => {
const model = editor.getModel();
if (!model) return;

const tab = useTabsStore.getState().allTabs[selectedFileRef.current];
if (!tab) return;
const {row, col} = tab;

// Clamp row/column to model size
const lineNumber = Math.min(row, model.getLineCount());
const column = Math.min(col, model.getLineMaxColumn(lineNumber));

editor.setPosition({lineNumber, column});
const padding = 5;
editor.revealRangeInCenter({
startLineNumber: Math.max(1, lineNumber - padding),
startColumn: 1,
endLineNumber: lineNumber + padding,
endColumn: 1,
});
}, []);

const handleEditorDidMount = (editor: monacoEditor.editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor;
setMounted(true);
editor.focus();

editor.addCommand(
monaco.KeyMod.Alt | monaco.KeyCode.KeyL,
async () => {
const {val, err} = await callRPC(() => file.format({filename: selectedFile}))
const {val, err} = await callRPC(() => file.format({filename: selectedFileRef.current}))
if (err) {
showError(err)
} else {
Expand Down Expand Up @@ -64,57 +93,31 @@ export function MonacoEditor(
}
);

editorRef.current?.getValue();

editor.onDidChangeCursorPosition((e) => {
const {lineNumber, column} = e.position;
saveLineNum({filename: selectedFile, col: column, row: lineNumber}, (value) => {
saveLineNum({filename: selectedFileRef.current, col: column, row: lineNumber}, (value) => {
setTabDetails(value.filename, {row: value.row, col: value.col});
});
});

restoreCaret(editor);
};

// when the active file changes the editor swaps to that file's kept model;
// restore the caret for the newly-activated file
useEffect(() => {
if (!mounted || !editorRef.current) return;

const model = editorRef.current.getModel();
if (!model) return;

// console.log("clearing stack for initial load");
model.pushStackElement();
model.setValue(fileContent);

model.onDidChangeContent(() => {
handleEditorChange(model.getValue());
});

const tab = useTabsStore.getState().allTabs[selectedFile];
if (!tab) return;
const {row, col} = tab;

// Clamp row/column to model size
const lineNumber = Math.min(row, model.getLineCount());
const column = Math.min(col, model.getLineMaxColumn(lineNumber));

editorRef.current.setPosition({lineNumber, column});
const padding = 5;
editorRef.current.revealRangeInCenter({
startLineNumber: Math.max(1, lineNumber - padding),
startColumn: 1,
endLineNumber: lineNumber + padding,
endColumn: 1,
});
// do not add tabs as dependencies
// it will mess with the editor typing
// resetting cursor position when the tab
}, [fileContent, selectedFile, mounted]);
const editor = editorRef.current;
if (editor) restoreCaret(editor);
}, [selectedFile, restoreCaret]);

return (
<Editor
key={selectedFile}
language={getLanguageFromExtension(selectedFile)}
defaultValue={""}
path={modelPath}
keepCurrentModel
defaultLanguage={getLanguageFromExtension(selectedFile)}
defaultValue={fileContent}
onMount={handleEditorDidMount}
onChange={handleEditorChange}
theme="vs-dark"
options={{
tabSize: 2,
Expand Down
35 changes: 33 additions & 2 deletions ui/src/pages/compose/components/file-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {useFileCreate} from "../dialogs/file-create.tsx";
import {useFileDelete} from "../dialogs/file-delete.tsx";
import {useFileRename} from "../dialogs/file-rename.tsx";
import {useAliasStore, useHostStore, useOpenFiles} from "../state/files.ts";
import {useEditorSave} from "../state/save.ts";
import {useConfig} from "../../../hooks/config.ts";
import {useComposeFileState} from "../state/status.ts";
import {getContextKey} from "../../../context/tab-context.tsx";
Expand Down Expand Up @@ -136,6 +137,9 @@ const FolderItemDisplay = ({entry, depthIndex}: {
// Highlight if we are currently editing the compose file this folder points to
const isSelected = useIsSelected(composeFilePath);

// reflect unsaved edits of the compose file this folder points to
const isDirty = useEditorSave(state => state.dirtyFiles[entry.isComposeFolder] ?? false);

const closeComposeStatus = useComposeFileState(state => state.delete)

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -222,7 +226,8 @@ const FolderItemDisplay = ({entry, depthIndex}: {
textDecoration: 'none'
}}
>
<ListItemIcon sx={{minWidth: 32}}>
<ListItemIcon sx={{minWidth: 32, position: 'relative'}}>
{isComposeFolder && <UnsavedDot dirty={isDirty}/>}
{isComposeFolder ?
<DockerFolderIcon/> :
<Folder sx={{color: amber[800], fontSize: '1.1rem'}}/>
Expand Down Expand Up @@ -330,6 +335,7 @@ const FileItemDisplay = ({entry}: { entry: FsEntry }) => {

const isSelected = useIsSelected(filePath);
const displayName = getEntryDisplayName(filename);
const isDirty = useEditorSave(state => state.dirtyFiles[filename] ?? false);

const {contextMenu, closeCtxMenu, contextActions, handleContextMenu} = useFileMenuCtx(entry)

Expand All @@ -347,7 +353,8 @@ const FileItemDisplay = ({entry}: { entry: FsEntry }) => {
to={filePath}
component={RouterLink}
>
<ListItemIcon sx={{minWidth: 32}}>
<ListItemIcon sx={{minWidth: 32, position: 'relative'}}>
<UnsavedDot dirty={isDirty}/>
{<FileIcon filename={filename}/>}
</ListItemIcon>

Expand Down Expand Up @@ -497,6 +504,30 @@ const useFileMenuCtx = (entry: FsEntry) => {
return {closeCtxMenu, contextActions, contextMenu, handleContextMenu}
}

// Amber dot shown on the LEFT of a file/compose-folder that has unsaved
// edits. Kept visually distinct from the docker StatusIndicator (right side).
const UnsavedDot = ({dirty}: { dirty: boolean }) => {
if (!dirty) return null;
return (
<Tooltip title="Unsaved changes" arrow placement="right">
<Box
sx={{
position: 'absolute',
top: -1,
left: -1,
width: 9,
height: 9,
borderRadius: '50%',
bgcolor: 'warning.main',
border: '2px solid',
borderColor: 'background.paper',
zIndex: 1,
}}
/>
</Tooltip>
);
};

const StatusIndicator = ({fileStatus}: { fileStatus: Status }) => {
const stackStatus = getStatusTheme(fileStatus);

Expand Down
22 changes: 22 additions & 0 deletions ui/src/pages/compose/components/viewer-dockyml.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {callRPC, useHostClient} from "../../../lib/api.ts";import {DockyamlService} from "../../../gen/dockyaml/v1/dockyaml_pb.ts";
import {Box, Button, capitalize, Tooltip, Typography} from '@mui/material';
import {SaveOutlined} from "@mui/icons-material";
import {indicatorMap, type SaveState} from "../hooks/status-hook.tsx";
import {useEditorSave} from "../state/save.ts";
import {ShortcutFormatter} from "./shortcut-formatter.tsx";
import {useConfig} from "../../../hooks/config.ts";
import {useState} from "react";
import EditorCommon from "./editor-common.tsx";
Expand Down Expand Up @@ -34,6 +37,9 @@ function DockyamlViewer({filename}: { filename: string }) {

const [saveStatus, setSaveStatus] = useState<SaveState>('idle')

const isDirty = useEditorSave(state => state.dirtyFiles[filename] ?? false)
const saveNow = useEditorSave(state => state.savers[filename])

const refreshFile = async () => {
await getFile()
}
Expand Down Expand Up @@ -101,6 +107,22 @@ function DockyamlViewer({filename}: { filename: string }) {
</Button>
</Tooltip>

<Tooltip title={<ShortcutFormatter title={"Save file"} keyCombo={["CTRL", "S"]}/>}>
<span>
<Button
size="small"
variant="outlined"
color="success"
disabled={!isDirty}
onClick={() => saveNow?.()}
startIcon={<SaveOutlined sx={{fontSize: 16}}/>}
sx={{fontSize: '0.75rem', textTransform: 'none'}}
>
Save
</Button>
</span>
</Tooltip>

<Typography variant="caption" sx={{
px: 1,
py: 0.2,
Expand Down
Loading