feat(desktop): in-app spot editor for the file preview pane

Adds a CodeMirror 6 spot editor to the right-rail file preview so users can
make quick edits in-app without leaving for an IDE. Entering edit mode is a
pure in-place swap of the read view — same fixed-height header, same gutter
geometry/typography (mirrors SourceView 1:1) so nothing shifts — toggled via
the Edit button, a bare `e` when the pane is hovered/focused, or the tab.

- Save path is transport-agnostic (writeDesktopFileText): local Electron IPC
  or a new hardened POST /api/fs/write-text on the dashboard server (path
  validation, parent-must-exist, regular-files-only, size cap, atomic
  temp-file + os.replace), behind the existing auth middleware.
- Stale-on-disk guard re-reads before writing and offers overwrite vs
  discard-and-reload instead of clobbering external/agent edits.
- VS Code-style modified dot on the tab; ⌘/Ctrl+S and ⌘/Ctrl+Enter save,
  Esc cancels; GitHub highlight style matched to the read view's Shiki theme.
- Typing stays render-free (draft in a ref; dirty flips once at the boundary).
This commit is contained in:
Brooklyn Nicholson 2026-06-25 19:50:25 -05:00
parent 6dfb8326f5
commit ff81365988
14 changed files with 1409 additions and 587 deletions

View file

@ -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",

View file

@ -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<PreviewViewMode, string> = {
diff: t.preview.diff,
@ -325,26 +355,68 @@ function PreviewModeSwitcher({
}
return (
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
{modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
// Fixed height so the header is byte-identical between read and edit modes —
// swapping the trailing controls must never move the body below it.
<div className="flex h-7 shrink-0 items-center justify-end gap-3 border-b border-border/40 px-3">
{showModes &&
modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
{trailing && <div className="flex items-center gap-1.5">{trailing}</div>}
</div>
)
}
// 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 (
<>
<button
className="flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCancel}
type="button"
>
<X className="size-3" />
{t.common.cancel}
</button>
<button
className="flex items-center gap-1 rounded-md bg-primary px-2 py-0.5 text-[0.625rem] font-bold text-primary-foreground shadow-xs transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={!dirty || saving}
onClick={onSave}
type="button"
>
<Check className="size-3" />
{saving ? t.common.saving : t.common.save}
</button>
</>
)
}
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 | PreviewViewMode>(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 | string>(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<HTMLDivElement>(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 (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<PreviewModeSwitcher
active="source"
modes={[]}
onSelect={() => {}}
trailing={<EditControls dirty={dirty} onCancel={cancelEdit} onSave={() => void saveEdit()} saving={saving} />}
/>
{conflict && (
<div className="shrink-0 border-b border-amber-400/40 bg-amber-50 px-3 py-2 text-[0.7rem] text-amber-900 dark:border-amber-300/30 dark:bg-amber-300/10 dark:text-amber-100">
<div className="font-semibold">{t.preview.diskChangedTitle}</div>
<div className="mt-0.5 leading-relaxed">{t.preview.diskChangedBody}</div>
<div className="mt-1.5 flex gap-3">
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={() => void saveEdit(true)}
type="button"
>
{t.preview.overwrite}
</button>
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={discardAndReload}
type="button"
>
{t.preview.discardReload}
</button>
</div>
</div>
)}
{saveError && (
<div className="shrink-0 border-b border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[0.7rem] text-destructive">
{t.preview.saveFailed(saveError)}
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<CodeEditor
filePath={filePath}
initialValue={baselineRef.current}
key={editorKey}
onCancel={cancelEdit}
onChange={handleEditorChange}
onSave={() => void saveEdit()}
/>
</div>
</div>
)
}
if (state.loading) {
return <PageLoader label={t.preview.loading} />
@ -647,13 +925,39 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<div
className="flex h-full flex-col overflow-hidden bg-transparent"
onMouseEnter={() => {
hoverRef.current = true
}}
onMouseLeave={() => {
hoverRef.current = false
}}
ref={readViewRef}
>
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
{t.preview.truncated}
</div>
)}
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
<PreviewModeSwitcher
active={mode}
modes={modes}
onSelect={setUserMode}
trailing={
canEdit ? (
<button
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
onClick={beginEdit}
title={`${t.preview.edit} (e)`}
type="button"
>
<Pencil className="size-3" />
{t.preview.edit}
</button>
) : null
}
/>
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />

View file

@ -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<readonly RailTab[]>(
() => [
@ -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 (
<ContextMenu key={tab.id}>
@ -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 && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
>
<span className="size-2 rounded-full bg-current" />
</span>
)}
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"

View file

@ -0,0 +1,99 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { tags as t } from '@lezer/highlight'
// GitHub "default" palettes, mirroring the read view's Shiki themes
// (`github-light-default` / `github-dark-default`) so the spot editor matches
// the preview it replaces. These are token *colors*; CodeMirror tokenizes with
// Lezer rather than Shiki's TextMate grammars, so it's a palette match rather
// than a byte-identical one — close enough that switching in/out of edit mode
// doesn't recolor the file.
interface GithubPalette {
comment: string
constant: string
entity: string
fg: string
keyword: string
number: string
string: string
tag: string
type: string
}
const DARK: GithubPalette = {
comment: '#8b949e',
constant: '#79c0ff',
entity: '#d2a8ff',
fg: '#e6edf3',
keyword: '#ff7b72',
number: '#79c0ff',
string: '#a5d6ff',
tag: '#7ee787',
type: '#ffa657'
}
const LIGHT: GithubPalette = {
comment: '#6e7781',
constant: '#0550ae',
entity: '#8250df',
fg: '#1f2328',
keyword: '#cf222e',
number: '#0550ae',
string: '#0a3069',
tag: '#116329',
type: '#953800'
}
function makeHighlightStyle(p: GithubPalette): HighlightStyle {
return HighlightStyle.define([
{ color: p.keyword, tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.moduleKeyword] },
{ color: p.string, tag: [t.string, t.special(t.string), t.attributeValue] },
{ color: p.tag, tag: [t.regexp, t.escape] },
{ color: p.comment, fontStyle: 'italic', tag: [t.comment, t.lineComment, t.blockComment, t.docComment] },
{
color: p.entity,
tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.function(t.variableName)), t.labelName]
},
{ color: p.number, tag: [t.number, t.bool, t.atom] },
{ color: p.constant, tag: [t.constant(t.variableName), t.standard(t.variableName)] },
{ color: p.type, tag: [t.typeName, t.className, t.namespace] },
{ color: p.tag, tag: [t.tagName] },
{ color: p.constant, tag: [t.attributeName, t.propertyName] },
{ color: p.fg, tag: [t.variableName] },
{ color: p.fg, tag: [t.operator, t.punctuation, t.separator, t.bracket, t.angleBracket, t.derefOperator] },
{ color: p.comment, tag: [t.meta, t.processingInstruction] },
{ color: p.constant, tag: [t.link, t.url], textDecoration: 'underline' },
{ color: p.constant, fontWeight: 'bold', tag: [t.heading] },
{ fontWeight: 'bold', tag: [t.strong] },
{ fontStyle: 'italic', tag: [t.emphasis] },
{ color: p.keyword, tag: [t.deleted, t.invalid] }
])
}
const DARK_STYLE = makeHighlightStyle(DARK)
const LIGHT_STYLE = makeHighlightStyle(LIGHT)
// Editor chrome (caret, selection, active line, gutters) on a transparent
// background so the pane surface shows through, paired with the matching
// GitHub highlight style.
export function githubEditorTheme(dark: boolean): Extension {
const p = dark ? DARK : LIGHT
return [
EditorView.theme(
{
'&': { backgroundColor: 'transparent', color: p.fg },
'&.cm-focused .cm-selectionBackground, .cm-content ::selection, .cm-selectionBackground': {
backgroundColor: dark ? 'rgba(56,139,253,0.25)' : 'rgba(84,174,255,0.28)'
},
// Match the read view's gutter: dim, right-aligned line numbers.
'.cm-content': { caretColor: p.fg },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: p.fg },
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' }
},
{ dark }
),
syntaxHighlighting(dark ? DARK_STYLE : LIGHT_STYLE)
]
}

View file

@ -0,0 +1,202 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { bracketMatching, indentOnInput, LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { Compartment, EditorState } from '@codemirror/state'
import { drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view'
import { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { useTheme } from '@/themes/context'
import { githubEditorTheme } from './code-editor-theme'
interface CodeEditorProps {
className?: string
filePath: string
// Read once at mount. To load a different file or discard edits, remount the
// component (give it a new React `key`) rather than pushing a new value in.
initialValue: string
onCancel?: () => void
onChange: (value: string) => void
onSave?: () => void
}
function baseName(filePath: string): string {
const cleaned = filePath.replace(/[\\/]+$/, '')
return cleaned.slice(cleaned.lastIndexOf('/') + 1).split('\\').pop() ?? cleaned
}
// Mirror SourceView's geometry/typography 1:1 so toggling preview⇄edit never
// shifts the file. CM's base stylesheet targets some of these with two-class
// selectors (e.g. `.cm-lineNumbers .cm-gutterElement`) that out-specify a bare
// `.cm-gutterElement` rule, so we match that specificity to win. SourceView
// reference: font var(--font-mono)/0.7rem/400, 1.25rem rows, gutter w-9 + pr-2
// (muted/55), code 0.625rem line inset.
const MONO_FONT = 'var(--font-mono)'
const ROW_HEIGHT = '1.25rem'
const CODE_SIZE = '0.7rem'
const GUTTER_COLOR = 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)'
const LAYOUT_THEME = EditorView.theme({
'&': {
WebkitFontSmoothing: 'antialiased',
backgroundColor: 'transparent',
height: '100%'
},
'.cm-content': {
fontFamily: MONO_FONT,
fontSize: CODE_SIZE,
fontWeight: '400',
lineHeight: ROW_HEIGHT,
padding: '0'
},
'.cm-gutters': {
backgroundColor: 'transparent',
border: 'none',
color: GUTTER_COLOR,
fontFamily: MONO_FONT,
fontSize: CODE_SIZE
},
// Two-class selector to beat CM's base `.cm-lineNumbers .cm-gutterElement`.
'.cm-lineNumbers .cm-gutterElement': {
boxSizing: 'border-box',
fontVariantNumeric: 'tabular-nums',
fontWeight: '400',
lineHeight: ROW_HEIGHT,
minWidth: '2.25rem',
padding: '0 0.5rem 0 0',
textAlign: 'right'
},
'.cm-line': {
fontFamily: MONO_FONT,
fontSize: CODE_SIZE,
fontWeight: '400',
lineHeight: ROW_HEIGHT,
padding: '0 0.625rem'
},
'.cm-scroller': {
fontFamily: MONO_FONT,
fontSize: CODE_SIZE,
lineHeight: ROW_HEIGHT,
overflow: 'auto'
}
})
// A deliberately small CodeMirror 6 surface for *spot edits* — not an IDE: line
// numbers, history, selection, bracket matching, syntax highlighting. No fold
// gutter, autocomplete, or active-line chrome, so it reads like the preview it
// replaces. It owns its own buffer; the parent tracks dirty via `onChange` and
// resets by remounting. ⌘/Ctrl+S and ⌘/Ctrl+Enter save; Esc cancels; the app's
// light/dark mode is followed live without losing the cursor.
export function CodeEditor({ className, filePath, initialValue, onCancel, onChange, onSave }: CodeEditorProps) {
const { resolvedMode } = useTheme()
const hostRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const languageConf = useRef(new Compartment())
const themeConf = useRef(new Compartment())
const onCancelRef = useRef(onCancel)
const onChangeRef = useRef(onChange)
const onSaveRef = useRef(onSave)
onCancelRef.current = onCancel
onChangeRef.current = onChange
onSaveRef.current = onSave
useEffect(() => {
const host = hostRef.current
if (!host) {
return
}
const isDark = resolvedMode === 'dark'
const save = () => {
onSaveRef.current?.()
return true
}
const state = EditorState.create({
doc: initialValue,
extensions: [
lineNumbers(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
keymap.of([
...defaultKeymap,
...historyKeymap,
indentWithTab,
{ key: 'Mod-s', preventDefault: true, run: save },
{ key: 'Mod-Enter', preventDefault: true, run: save },
{
key: 'Escape',
run: () => {
if (!onCancelRef.current) {
return false
}
onCancelRef.current()
return true
}
}
]),
languageConf.current.of([]),
themeConf.current.of(githubEditorTheme(isDark)),
EditorView.updateListener.of(update => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString())
}
}),
LAYOUT_THEME
]
})
const view = new EditorView({ parent: host, state })
viewRef.current = view
// Focus on mount so entering edit mode (button or double-click) lands the
// caret in the buffer ready to type, no extra click required.
view.focus()
return () => {
view.destroy()
viewRef.current = null
}
// Created once per mount; the parent remounts (via `key`) to load a new
// file or discard. Theme/language are applied reactively below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load + apply syntax highlighting for the file's language (lazy per language).
useEffect(() => {
let cancelled = false
const description = LanguageDescription.matchFilename(languages, baseName(filePath))
if (!description) {
viewRef.current?.dispatch({ effects: languageConf.current.reconfigure([]) })
return
}
void description.load().then(support => {
if (!cancelled && viewRef.current) {
viewRef.current.dispatch({ effects: languageConf.current.reconfigure(support) })
}
})
return () => {
cancelled = true
}
}, [filePath])
useEffect(() => {
viewRef.current?.dispatch({
effects: themeConf.current.reconfigure(githubEditorTheme(resolvedMode === 'dark'))
})
}, [resolvedMode])
return <div className={cn('h-full min-h-0 overflow-hidden', className)} ref={hostRef} />
}

View file

@ -1902,6 +1902,14 @@ export const en: Translations = {
truncated: 'Showing first 512 KB.',
noInlineTitle: 'No inline preview',
noInlineBody: mimeType => `${mimeType || 'This file type'} can still be attached as context.`,
edit: 'Edit',
editing: 'Editing',
unsavedChanges: 'Unsaved changes',
saveFailed: message => `Couldn't save: ${message}`,
diskChangedTitle: 'File changed on disk',
diskChangedBody: 'This file changed since you opened it. Overwrite it with your version, or discard your edits and reload?',
overwrite: 'Overwrite',
discardReload: 'Discard & reload',
console: {
deselect: 'Deselect entry',
select: 'Select entry',

View file

@ -2026,6 +2026,14 @@ export const ja = defineLocale({
truncated: '最初の 512 KB を表示しています。',
noInlineTitle: 'インラインプレビューなし',
noInlineBody: mimeType => `${mimeType || 'このファイルタイプ'} はコンテキストとして添付できます。`,
edit: '編集',
editing: '編集中',
unsavedChanges: '未保存の変更',
saveFailed: message => `保存できませんでした:${message}`,
diskChangedTitle: 'ファイルがディスク上で変更されました',
diskChangedBody: 'このファイルは開いてから変更されています。あなたの版で上書きするか、編集を破棄して再読み込みしますか?',
overwrite: '上書き',
discardReload: '破棄して再読み込み',
console: {
deselect: 'エントリーの選択を解除',
select: 'エントリーを選択',

View file

@ -1561,6 +1561,14 @@ export interface Translations {
truncated: string
noInlineTitle: string
noInlineBody: (mimeType: string) => string
edit: string
editing: string
unsavedChanges: string
saveFailed: (message: string) => string
diskChangedTitle: string
diskChangedBody: string
overwrite: string
discardReload: string
console: {
deselect: string
select: string

View file

@ -1965,6 +1965,14 @@ export const zhHant = defineLocale({
truncated: '顯示前 512 KB。',
noInlineTitle: '沒有行內預覽',
noInlineBody: mimeType => `${mimeType || '此檔案類型'} 仍可作為脈絡附件。`,
edit: '編輯',
editing: '編輯中',
unsavedChanges: '未儲存的變更',
saveFailed: message => `無法儲存:${message}`,
diskChangedTitle: '檔案已在磁碟上變更',
diskChangedBody: '此檔案自開啟以來已變更。用你的版本覆寫,還是放棄你的編輯並重新載入?',
overwrite: '覆寫',
discardReload: '放棄並重新載入',
console: {
deselect: '取消選取項目',
select: '選取項目',

View file

@ -2077,6 +2077,14 @@ export const zh: Translations = {
truncated: '显示前 512 KB。',
noInlineTitle: '没有内联预览',
noInlineBody: mimeType => `${mimeType || '此文件类型'} 仍可作为上下文附件。`,
edit: '编辑',
editing: '编辑中',
unsavedChanges: '未保存的更改',
saveFailed: message => `无法保存:${message}`,
diskChangedTitle: '文件已在磁盘上更改',
diskChangedBody: '此文件自打开以来已更改。用你的版本覆盖,还是放弃你的编辑并重新加载?',
overwrite: '覆盖',
discardReload: '放弃并重新加载',
console: {
deselect: '取消选择条目',
select: '选择条目',

View file

@ -66,6 +66,30 @@ export async function readDesktopFileText(path: string): Promise<HermesReadFileT
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
// Save UTF-8 text back to a file. Local writes go through the hardened Electron
// IPC; remote writes hit the dashboard's POST /api/fs/write-text (same path
// hardening, parent-must-exist, size cap) so the editor behaves identically in
// both modes. Stale-on-disk detection is the caller's job (re-read before save).
export async function writeDesktopFileText(path: string, content: string): Promise<{ path: string }> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
if (!desktop.writeTextFile) {
throw new Error('Saving is not available')
}
return desktop.writeTextFile(path, content)
}
const result = await desktop.api<{ ok?: boolean; path?: string }>({
body: { content, path },
method: 'POST',
path: '/api/fs/write-text'
})
return { path: result.path || path }
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()

View file

@ -0,0 +1,30 @@
import { atom } from 'nanostores'
// URLs of preview targets that have unsaved spot-editor changes, keyed by
// `target.url` so the rail can render a VS Code-style "modified" dot on the tab
// without threading editor state up through the pane. The editor in
// `preview-file.tsx` is the sole writer; the rail tabs are the readers.
export const $dirtyPreviewUrls = atom<Record<string, true>>({})
export function setPreviewDirty(url: string, dirty: boolean): void {
if (!url) {
return
}
const current = $dirtyPreviewUrls.get()
const has = Boolean(current[url])
if (dirty === has) {
return
}
if (dirty) {
$dirtyPreviewUrls.set({ ...current, [url]: true })
return
}
const next = { ...current }
delete next[url]
$dirtyPreviewUrls.set(next)
}

View file

@ -1088,6 +1088,10 @@ _FS_READDIR_HIDDEN = {
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
# Upper bound for the in-app spot editor's save. The editor only opens
# non-truncated text (<= the preview cap), so this is a safety ceiling against
# a pasted-in megablob, not the expected payload size.
_FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024
_FS_PREVIEW_LANGUAGE_BY_EXT = {
".c": "c",
".conf": "ini",
@ -1792,6 +1796,58 @@ async def fs_read_text(path: str):
}
class FsWriteText(BaseModel):
path: str
content: str
@app.post("/api/fs/write-text")
async def fs_write_text(payload: FsWriteText):
"""Overwrite (or create) a UTF-8 text file for the in-app spot editor.
Mirrors the local Electron ``hermes:fs:writeText`` hardening: the path is
resolved + validated by ``_fs_path``, the parent directory must already
exist (we never build directory trees), only regular files may be replaced,
and the payload is size-capped. The write is staged to a sibling temp file
and ``os.replace``-d into place so a crash mid-write can't truncate the
original. Stale-on-disk detection is the client's job (re-read before save),
so both transports behave identically.
"""
target = _fs_path(payload.path)
text = payload.content or ""
if len(text.encode("utf-8")) > _FS_TEXT_WRITE_MAX_BYTES:
raise HTTPException(status_code=413, detail="Content too large")
try:
st: Optional[os.stat_result] = target.stat()
except FileNotFoundError:
st = None
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if st is not None and stat.S_ISDIR(st.st_mode):
raise HTTPException(status_code=400, detail="Path points to a directory")
if st is not None and not stat.S_ISREG(st.st_mode):
raise HTTPException(status_code=400, detail="Only regular files can be written")
if not target.parent.is_dir():
raise HTTPException(status_code=400, detail="Parent directory does not exist")
tmp = target.with_name(f".{target.name}.hermes-tmp-{os.getpid()}")
try:
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, target)
except PermissionError:
tmp.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
tmp.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
return {"ok": True, "path": str(target), "byteSize": len(text.encode("utf-8"))}
@app.get("/api/fs/read-data-url")
async def fs_read_data_url(path: str):
target, st = _fs_regular_file(_fs_path(path))

1180
package-lock.json generated

File diff suppressed because it is too large Load diff