diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 62cb60c61e2..6d8a9a66209 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -51,11 +51,17 @@ "@assistant-ui/react-streamdown": "^0.1.11", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/language-data": "^6.5.2", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.43.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", "@icons-pack/react-simple-icons": "=13.11.1", + "@lezer/highlight": "^1.2.3", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index ef0c7d185ff..e58f37490ec 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { Fragment, useEffect, useMemo, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' @@ -14,15 +14,25 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' +import { CodeEditor } from '@/components/chat/code-editor' import { FileDiffPanel } from '@/components/chat/diff-lines' import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' -import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { + desktopFileDiff, + desktopGitRoot, + readDesktopFileDataUrl, + readDesktopFileText, + writeDesktopFileText +} from '@/lib/desktop-fs' +import { Check, Pencil, X } from '@/lib/icons' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' +import { setPreviewDirty } from '@/store/preview-edit' import { $currentCwd } from '@/store/session' +import { notifyWorkspaceChanged } from '@/store/workspace-events' const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 @@ -141,6 +151,19 @@ interface LocalPreviewState { truncated?: boolean } +// True when focus is in a field that should swallow plain keystrokes (so the +// bare-`e` edit shortcut never fires while the user is typing in the composer, +// a search box, or the editor itself). +function isTypableElement(el: Element | null): boolean { + if (!el) { + return false + } + + const tag = el.tagName + + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable +} + function filePathForTarget(target: PreviewTarget) { if (target.path) { return target.path @@ -310,13 +333,20 @@ function MarkdownPreview({ text }: { text: string }) { function PreviewModeSwitcher({ active, modes, - onSelect + onSelect, + trailing }: { active: PreviewViewMode modes: PreviewViewMode[] onSelect: (mode: PreviewViewMode) => void + trailing?: ReactNode }) { const { t } = useI18n() + const showModes = modes.length > 1 + + if (!showModes && !trailing) { + return null + } const label: Record = { diff: t.preview.diff, @@ -325,26 +355,68 @@ function PreviewModeSwitcher({ } return ( -
- {modes.map(mode => ( - - ))} + // Fixed height so the header is byte-identical between read and edit modes — + // swapping the trailing controls must never move the body below it. +
+ {showModes && + modes.map(mode => ( + + ))} + {trailing &&
{trailing}
}
) } +// Cancel / Save controls rendered as the header's trailing slot (not a bar of +// their own) so edit mode reuses the read-mode header row verbatim. +function EditControls({ + dirty, + onCancel, + onSave, + saving +}: { + dirty: boolean + onCancel: () => void + onSave: () => void + saving: boolean +}) { + const { t } = useI18n() + + return ( + <> + + + + ) +} + interface LineSelection { end: number start: number @@ -492,11 +564,36 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar // User-picked view; null = auto (diff when changed, else rendered markdown, // else source). Reset when the previewed file changes. const [userMode, setUserMode] = useState(null) + // Spot-editor state. The editor owns its buffer (keyed by `editorKey`); the + // live draft + the snapshot the user started from live in refs so typing + // never re-renders this (large) component — `dirty` is the only render-worthy + // signal and it flips just once when crossing the clean↔dirty boundary. + // `selfReload` re-runs the load after a save without the parent. + const [editing, setEditing] = useState(false) + const draftRef = useRef('') + const baselineRef = useRef('') + const [dirty, setDirty] = useState(false) + const [editorKey, setEditorKey] = useState(0) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [conflict, setConflict] = useState(false) + const [selfReload, setSelfReload] = useState(0) + // For the bare-`e` shortcut: the read-view root (to detect focus-within) and a + // hover flag (no state — only the keydown handler reads it). + const readViewRef = useRef(null) + const hoverRef = useRef(false) const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' useEffect(() => { setUserMode(null) + setEditing(false) + setDirty(false) + setSaving(false) + setSaveError(null) + setConflict(false) + draftRef.current = '' + baselineRef.current = '' }, [filePath, reloadKey]) // HTML files are rendered as source code, not in a webview - so they take @@ -582,7 +679,188 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar return () => { active = false } - }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language]) + }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, selfReload, target.dataUrl, target.language]) + + // Editing is only offered for whole, readable text — never images, binaries, + // or files we only loaded the first 512 KB of (saving would drop the tail). + const canEdit = + isText && !isImage && !blockedByTarget && state.text !== undefined && !state.truncated && !state.binary + + // Per-keystroke: update the draft ref (no render) and only set `dirty` when it + // actually changes — React bails on an identical value, so a long typing run + // triggers a single re-render at most. + const handleEditorChange = useCallback((value: string) => { + draftRef.current = value + const next = value !== baselineRef.current + setDirty(prev => (prev === next ? prev : next)) + }, []) + + // Publish the unsaved state to the rail so the tab can show a modified dot. + // Keyed by url; cleared on unmount/tab-change so a stale dot never lingers. + useEffect(() => { + setPreviewDirty(target.url, editing && dirty) + + return () => setPreviewDirty(target.url, false) + }, [target.url, editing, dirty]) + + const beginEdit = () => { + const text = state.text ?? '' + baselineRef.current = text + draftRef.current = text + setDirty(false) + setEditorKey(key => key + 1) + setSaving(false) + setSaveError(null) + setConflict(false) + setEditing(true) + } + + // Latest `beginEdit` for the keydown listener, so the listener can stay + // subscribed across renders without recreating itself or going stale. + const beginEditRef = useRef(beginEdit) + beginEditRef.current = beginEdit + + // Bare `e` enters edit mode when the file pane is hovered or focused and no + // typable field has focus — a fast, button-free path (double-click felt laggy + // because of the browser's click-disambiguation delay). + useEffect(() => { + if (!canEdit || editing) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'e' || event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (isTypableElement(document.activeElement)) { + return + } + + const root = readViewRef.current + const focusWithin = Boolean(root && document.activeElement && root.contains(document.activeElement)) + + if (!hoverRef.current && !focusWithin) { + return + } + + event.preventDefault() + beginEditRef.current() + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [canEdit, editing]) + + const cancelEdit = () => { + setEditing(false) + setSaveError(null) + setConflict(false) + } + + const discardAndReload = () => { + setEditing(false) + setConflict(false) + setSaveError(null) + setSelfReload(n => n + 1) + } + + const saveEdit = async (force = false) => { + if (saving) { + return + } + + setSaving(true) + setSaveError(null) + + try { + // Stale-on-disk guard: re-read what's on disk now and compare to the + // snapshot the user started from. If something changed underneath (an + // agent edit, an external save), don't clobber it silently — surface the + // choice. `force` is the user picking "overwrite" from that banner. + if (!force) { + try { + const current = await readTextPreview(filePath) + + if (!current.binary && (current.text ?? '') !== baselineRef.current) { + setConflict(true) + setSaving(false) + + return + } + } catch { + // Couldn't re-read for the check — fall through and attempt the write. + } + } + + await writeDesktopFileText(filePath, draftRef.current) + baselineRef.current = draftRef.current + setDirty(false) + setConflict(false) + setEditing(false) + notifyWorkspaceChanged() + setSelfReload(n => n + 1) + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)) + } finally { + setSaving(false) + } + } + + // Rendered before the loading/error branches so a background re-read (file + // watcher, workspace tick) can't unmount the editor and drop the draft. Uses + // the SAME container + fixed-height header as the read view so entering edit + // never shifts the body — only the trailing controls and the body swap. + if (editing) { + return ( +
+ {}} + trailing={ void saveEdit()} saving={saving} />} + /> + {conflict && ( +
+
{t.preview.diskChangedTitle}
+
{t.preview.diskChangedBody}
+
+ + +
+
+ )} + {saveError && ( +
+ {t.preview.saveFailed(saveError)} +
+ )} +
+ void saveEdit()} + /> +
+
+ ) + } if (state.loading) { return @@ -647,13 +925,39 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar const mode = userMode && modes.includes(userMode) ? userMode : autoMode return ( -
+
{ + hoverRef.current = true + }} + onMouseLeave={() => { + hoverRef.current = false + }} + ref={readViewRef} + > {state.truncated && (
{t.preview.truncated}
)} - {modes.length > 1 && } + + + {t.preview.edit} + + ) : null + } + />
{mode === 'rendered' ? ( diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 97678cab106..9b7dc832a4a 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -31,6 +31,7 @@ import { closeRightRailTabsToRight, type PreviewTarget } from '@/store/preview' +import { $dirtyPreviewUrls } from '@/store/preview-edit' import { PreviewPane } from './preview-pane' @@ -70,6 +71,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const panesFlipped = useStore($panesFlipped) const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) + const dirtyPreviewUrls = useStore($dirtyPreviewUrls) const tabs = useMemo( () => [ @@ -109,6 +111,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const active = tab.id === activeTab.id const hasOthers = tabs.length > 1 const hasTabsToRight = index < tabs.length - 1 + const dirty = Boolean(dirtyPreviewUrls[tab.target.url]) return ( @@ -155,6 +158,14 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100" /> + {dirty && ( +