diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index d2a8065d..6643e5b3 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -81,6 +81,14 @@ export class FileSystemBrowser { return { path: relativePath, absolutePath } } + writeFile(relativePath: string, contents: string): void { + if (this.unrestricted) { + throw new Error("writeFile is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + fs.writeFileSync(resolved, contents, "utf-8") + } + readFile(relativePath: string): string { if (this.unrestricted) { throw new Error("readFile is not available in unrestricted mode") diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 1541475d..de2afd22 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), }) +const WorkspaceFileContentBodySchema = z.object({ + contents: z.string(), +}) + const WorkspaceFileSearchQuerySchema = z.object({ q: z.string().trim().min(1, "Query is required"), limit: z.coerce.number().int().positive().max(200).optional(), @@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { return handleWorkspaceError(error, reply) } }) + + app.put<{ + Params: { id: string } + Querystring: { path?: string } + }>("/api/workspaces/:id/files/content", async (request, reply) => { + try { + const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) + const body = WorkspaceFileContentBodySchema.parse(request.body ?? {}) + deps.workspaceManager.writeFile(request.params.id, query.path, body.contents) + reply.code(204) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 602589ee..805b8f95 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -83,6 +83,12 @@ export class WorkspaceManager { } } + writeFile(workspaceId: string, relativePath: string, contents: string): void { + const workspace = this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: workspace.path }) + browser.writeFile(relativePath, contents) + } + async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 7ef34570..20c8b989 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -108,15 +108,15 @@ const AlertDialog: Component = () => { open modal onOpenChange={(open) => { - if (!open) { + // Only handle dismiss if dialog is dismissible (default: true) + if (!open && payload.dismissible !== false) { dismiss(false, payload) } }} > - -
- + +
{
-
- -
- - +
+
+ + ) }} diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx index cffecf2d..4816f225 100644 --- a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx @@ -9,6 +9,8 @@ interface MonacoFileViewerProps { scopeKey: string path: string content: string + onSave?: (content: string) => void + onContentChange?: (content: string) => void } export function MonacoFileViewer(props: MonacoFileViewerProps) { @@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { editor = null } + const saveContent = () => { + if (!editor || !props.onSave) return + props.onSave(editor.getValue()) + } + onMount(() => { let cancelled = false void (async () => { @@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { editor = monaco.editor.create(host, { value: "", language: "plaintext", - readOnly: true, + readOnly: false, automaticLayout: true, lineNumbers: "on", minimap: { enabled: false }, @@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { fontSize: 13, }) + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent) + + editor.onDidChangeModelContent(() => { + if (props.onContentChange) { + props.onContentChange(editor.getValue()) + } + }) + setReady(true) })() diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 31684cbe..8f9c893c 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -44,6 +44,7 @@ const InstanceInfo: Component = (props) => { variant: "warning", confirmLabel: t("infoView.dispose.confirm.confirmLabel"), cancelLabel: t("infoView.dispose.confirm.cancelLabel"), + dismissible: false, }) if (!confirmed) return diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 750e15f8..bf10cc54 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -420,6 +420,7 @@ const InstanceShell2: Component = (props) => { onClose={closeLeftDrawer} ModalProps={modalProps} sx={{ + zIndex: 60, "& .MuiDrawer-paper": { width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`, boxSizing: "border-box", @@ -530,6 +531,7 @@ const InstanceShell2: Component = (props) => { onClose={closeRightDrawer} ModalProps={modalProps} sx={{ + zIndex: 60, "& .MuiDrawer-paper": { width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`, boxSizing: "border-box", diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index a57d11ed..8226105f 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" import { requestData } from "../../../../lib/opencode-api" +import { serverApi } from "../../../../lib/api-client" +import { showConfirmDialog } from "../../../../stores/alerts" +import { showToastNotification } from "../../../../lib/notifications" import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { @@ -102,6 +105,9 @@ const RightPanel: Component = (props) => { const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) const [browserSelectedError, setBrowserSelectedError] = createSignal(null) + const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false) + const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false) + const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null) const [diffViewMode, setDiffViewMode] = createSignal( readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", @@ -539,6 +545,8 @@ const RightPanel: Component = (props) => { setBrowserSelectedLoading(true) setBrowserSelectedError(null) setBrowserSelectedContent(null) + setBrowserSelectedDirty(false) + setBrowserSelectedOriginalContent(null) // Phone: treat file selection as a commit action and close the overlay. if (props.isPhoneLayout()) { @@ -559,6 +567,7 @@ const RightPanel: Component = (props) => { throw new Error("Unsupported file type") } setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) // Track original content for conflict detection } catch (error) { setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") } finally { @@ -566,6 +575,95 @@ const RightPanel: Component = (props) => { } } + const saveBrowserFile = async (content: string): Promise => { + const path = browserSelectedPath() + if (!path) return false + + // Check for conflict: agent edited file while user was editing + const originalContent = browserSelectedOriginalContent() + if (originalContent !== null) { + try { + const currentDiskContent = await requestData( + browserClient().file.read({ path }), + "file.read", + ) + const diskContent = (currentDiskContent as any)?.content + + // If disk content differs from what we originally loaded (agent edit) + // AND differs from user's current edits, we have a conflict + if (diskContent !== originalContent && diskContent !== content) { + const confirmed = await showConfirmDialog( + props.t("instanceShell.rightPanel.actions.conflict.message", { path }), + { + variant: "warning", + confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"), + cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), + dismissible: false, + }, + ) + if (!confirmed) { + return false + } + // User chose to overwrite, proceed with save + } + } catch { + // If we can't check for conflict, proceed with save + } + } + + setBrowserSelectedSaving(true) + try { + await serverApi.writeWorkspaceFile(props.instanceId, path, content) + setBrowserSelectedContent(content) + setBrowserSelectedOriginalContent(content) // Update original to match saved + setBrowserSelectedDirty(false) + showToastNotification({ + message: props.t("instanceShell.rightPanel.toast.saveSuccess"), + variant: "success", + }) + return true + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file") + showToastNotification({ + message: props.t("instanceShell.rightPanel.toast.saveError"), + variant: "error", + }) + return false + } finally { + setBrowserSelectedSaving(false) + } + } + + const handleBrowserFileChange = (content: string) => { + setBrowserSelectedContent(content) + setBrowserSelectedDirty(true) + } + + const handleOpenBrowserFileRequest = async (path: string) => { + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog( + props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }), + { + variant: "warning", + confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"), + cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"), + dismissible: false, + }, + ) + if (confirmed) { + const saveSuccess = await saveBrowserFile(browserSelectedContent() || "") + if (!saveSuccess) { + // Save failed - stay on current file, error toast already shown + return + } + } else { + // User chose not to save - clear dirty state and discard edits + setBrowserSelectedDirty(false) + } + } + await openBrowserFile(path) + } + createEffect(() => { if (rightPanelTab() !== "files") return if (browserLoading()) return @@ -578,6 +676,7 @@ const RightPanel: Component = (props) => { setBrowserSelectedContent(null) setBrowserSelectedLoading(false) setBrowserSelectedError(null) + setBrowserSelectedDirty(false) }) createEffect(() => { @@ -630,6 +729,22 @@ const RightPanel: Component = (props) => { } const refreshFilesTab = async () => { + // Prompt for confirmation if file has unsaved changes + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog( + props.t("instanceShell.rightPanel.actions.refreshDirty.message"), + { + variant: "warning", + confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), + cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"), + dismissible: false, + }, + ) + if (!confirmed) { + return + } + } + void loadBrowserEntries(browserPath()) const selected = browserSelectedPath() if (selected) { @@ -651,6 +766,8 @@ const RightPanel: Component = (props) => { throw new Error("Unsupported file type") } setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) // Update original content after refresh + setBrowserSelectedDirty(false) // Clear dirty after refresh } catch (error) { setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") } finally { @@ -830,11 +947,15 @@ const RightPanel: Component = (props) => { browserSelectedContent={browserSelectedContent} browserSelectedLoading={browserSelectedLoading} browserSelectedError={browserSelectedError} + browserSelectedDirty={browserSelectedDirty} + browserSelectedSaving={browserSelectedSaving} parentPath={browserParentPath} scopeKey={browserScopeKey} onLoadEntries={(path: string) => void loadBrowserEntries(path)} - onOpenFile={(path: string) => void openBrowserFile(path)} + onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} onRefresh={() => void refreshFilesTab()} + onSave={(content: string) => void saveBrowserFile(content)} + onContentChange={(content: string) => handleBrowserFileChange(content)} listOpen={filesListOpen} onToggleList={toggleFilesList} splitWidth={filesSplitWidth} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index 976d1431..1cb4f68e 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -1,7 +1,7 @@ import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js" import type { FileNode } from "@opencode-ai/sdk/v2/client" -import { RefreshCw } from "lucide-solid" +import { RefreshCw, Save } from "lucide-solid" import SplitFilePanel from "../components/SplitFilePanel" @@ -21,13 +21,17 @@ interface FilesTabProps { browserSelectedContent: Accessor browserSelectedLoading: Accessor browserSelectedError: Accessor + browserSelectedDirty: Accessor + browserSelectedSaving: Accessor parentPath: Accessor scopeKey: Accessor onLoadEntries: (path: string) => void - onOpenFile: (path: string) => void + onRequestOpenFile: (path: string) => void onRefresh: () => void + onSave: (content: string) => void + onContentChange: (content: string) => void listOpen: Accessor onToggleList: () => void @@ -38,6 +42,13 @@ interface FilesTabProps { } const FilesTab: Component = (props) => { + const handleSave = () => { + const content = props.browserSelectedContent() + if (content !== undefined && content !== null) { + props.onSave(content) + } + } + const renderContent = (): JSX.Element => { const entriesValue = props.browserEntries() const entries = entriesValue || [] @@ -86,7 +97,13 @@ const FilesTab: Component = (props) => {
} > - + )} @@ -135,7 +152,7 @@ const FilesTab: Component = (props) => { props.onLoadEntries(item.path) return } - props.onOpenFile(item.path) + props.onRequestOpenFile(item.path) }} title={item.path} > @@ -168,14 +185,25 @@ const FilesTab: Component = (props) => { {(err) => {err()}} - +