mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
parent
6dfb8326f5
commit
ff81365988
14 changed files with 1409 additions and 587 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
99
apps/desktop/src/components/chat/code-editor-theme.ts
Normal file
99
apps/desktop/src/components/chat/code-editor-theme.ts
Normal 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)
|
||||
]
|
||||
}
|
||||
202
apps/desktop/src/components/chat/code-editor.tsx
Normal file
202
apps/desktop/src/components/chat/code-editor.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'エントリーを選択',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '選取項目',
|
||||
|
|
|
|||
|
|
@ -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: '选择条目',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
30
apps/desktop/src/store/preview-edit.ts
Normal file
30
apps/desktop/src/store/preview-edit.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
1180
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue