Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { ArrowLeft, Save, X } from 'lucide-react';4import { useSettings } from '@/lib/queries';5import { basename, fmtBytes } from '@/lib/format';6import { toast } from '@/stores/toasts';7import { Button, IconButton } from '@/components/ui/button';8import { ConfirmDialog, Dialog } from '@/components/ui/dialog';9import { EmptyState, Spinner } from '@/components/ui/misc';10import { cn } from '@/lib/cn';11import { isDirty, isImagePath, isJsonPath, isMarkdownPath, isPdfPath, useEditor, type Buffer } from './editor-store';12import { CodeEditor, DiffView } from './lazy';13import { DownloadLink, FileMeta, ImagePreview, JsonPretty, MarkdownPreview, PdfPreview } from './previews';14import { useBrand } from '@/stores/branding';1516export function EditorPane({ mobile, onBack, className }: { mobile?: boolean; onBack?: () => void; className?: string }) {17 const { tabs, activeKey, activate, close, save, setContent } = useEditor();18 const settings = useSettings();19 const active = tabs.find((t) => t.key === activeKey) ?? null;20 const [view, setView] = useState<Record<string, 'edit' | 'preview'>>({});21 const [closing, setClosing] = useState<Buffer | null>(null);2223 const doSave = async (key: string) => {24 const r = await save(key);25 if (r === 'error') toast.error('Save failed', useEditor.getState().tabs.find((t) => t.key === key)?.error ?? undefined);26 };27 // Cmd/Ctrl+S outside Monaco (previews, tab bar)28 useEffect(() => {29 const onKey = (e: KeyboardEvent) => {30 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's' && activeKey) {31 e.preventDefault();32 void doSave(activeKey);33 }34 };35 window.addEventListener('keydown', onKey);36 return () => window.removeEventListener('keydown', onKey);37 // eslint-disable-next-line react-hooks/exhaustive-deps -- doSave is stable enough for a key handler38 }, [activeKey]);39 // Optional autosave (user setting): 1.5 s after the last change, never while in conflict.40 const dirtyContent = active && isDirty(active) && !active.conflict ? active.content : null;41 useEffect(() => {42 if (!settings.autosave || dirtyContent === null || !activeKey) return;43 const t = setTimeout(() => void doSave(activeKey), 1500);44 return () => clearTimeout(t);45 // eslint-disable-next-line react-hooks/exhaustive-deps46 }, [settings.autosave, dirtyContent, activeKey]);47 // Warn before leaving the page with unsaved changes.48 const anyDirty = tabs.some(isDirty);49 useEffect(() => {50 if (!anyDirty) return;51 const h = (e: BeforeUnloadEvent) => e.preventDefault();52 window.addEventListener('beforeunload', h);53 return () => window.removeEventListener('beforeunload', h);54 }, [anyDirty]);5556 const requestClose = (b: Buffer) => (isDirty(b) ? setClosing(b) : close(b.key));57 const mode = active ? (view[active.key] ?? 'edit') : 'edit';58 const canPreview = active?.kind === 'file' && active.status === 'ready' && (isMarkdownPath(active.path) || isJsonPath(active.path));5960 return (61 <div className={cn('flex h-full min-w-0 flex-col bg-surface', className)} data-testid="editor-pane">62 <div className="flex h-9 shrink-0 items-stretch border-b border-line bg-surface pointer-coarse:h-11">63 {onBack && (64 <IconButton label="Back to files" noTooltip onClick={onBack} className="self-center">65 <ArrowLeft className="size-4" />66 </IconButton>67 )}68 <div role="tablist" aria-label="Open files" className="no-scrollbar flex min-w-0 flex-1 items-stretch overflow-x-auto">69 {tabs.map((t) => (70 <div key={t.key} className={cn('group flex shrink-0 items-center border-r border-line', t.key === activeKey ? 'bg-surface-2' : 'hover:bg-surface-2')}>71 <button role="tab" aria-selected={t.key === activeKey} onClick={() => activate(t.key)} title={t.path} data-testid="editor-tab" className={cn('flex h-full items-center gap-1.5 pl-3 pr-1 text-xs', t.key === activeKey ? 'text-text' : 'text-text-2')}>72 {t.kind === 'diff' && <span className="text-[10px] font-semibold uppercase text-accent">diff</span>}73 <span className="max-w-40 truncate">{basename(t.path)}</span>74 {isDirty(t) && <span className="size-1.5 rounded-full bg-warning" role="img" aria-label="Unsaved changes" data-testid="dirty-dot" />}75 </button>76 <button aria-label={`Close ${basename(t.path)}`} onClick={() => requestClose(t)} className="mr-1 inline-flex size-5 items-center justify-center rounded-[4px] text-muted hover:bg-hover hover:text-text pointer-coarse:size-9">77 <X className="size-3" />78 </button>79 </div>80 ))}81 </div>82 </div>83 {active && (84 <div className="flex min-h-8 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-line px-3 py-1 text-[11px] text-muted">85 <span className="min-w-0 flex-1 truncate font-mono" title={active.path}>{active.path}</span>86 {active.kind === 'diff' && <span>{active.diffTitle}</span>}87 {active.kind === 'file' && active.status === 'ready' && (88 <>89 <span className="tnum hidden sm:inline">{fmtBytes(active.size)}</span>90 <span data-testid="save-state" className={cn(active.conflict ? 'text-error' : isDirty(active) ? 'text-warning' : 'text-muted')}>91 {active.saving ? 'Saving…' : active.conflict ? 'Conflict' : isDirty(active) ? 'Unsaved changes' : 'Saved'}92 </span>93 {canPreview && (94 <div role="group" aria-label="View mode" className="flex overflow-hidden rounded-sm border border-line-2">95 {(['edit', 'preview'] as const).map((m) => (96 <button key={m} aria-pressed={mode === m} onClick={() => setView((v) => ({ ...v, [active.key]: m }))} className={cn('h-6 px-2 text-[11px] pointer-coarse:h-9 pointer-coarse:px-3', mode === m ? 'bg-surface-2 text-text' : 'text-muted hover:text-text-2')}>97 {m === 'edit' ? 'Edit' : isJsonPath(active.path) ? 'Pretty' : 'Preview'}98 </button>99 ))}100 </div>101 )}102 <Button size="sm" variant={isDirty(active) ? 'primary' : 'secondary'} disabled={!isDirty(active)} loading={active.saving} onClick={() => doSave(active.key)} data-testid="save-file" className="pointer-coarse:h-9">103 <Save className="size-3.5" /> Save104 </Button>105 </>106 )}107 </div>108 )}109 <div className="relative min-h-0 flex-1">110 {!active ? (111 <EmptyState title="No file open" description="Select a file in the tree to view or edit it." className="h-full" />112 ) : (113 <BufferView buffer={active} mode={mode} mobile={mobile} fontSize={settings.terminalFontSize} onChange={(v) => setContent(active.key, v)} onSave={() => doSave(active.key)} />114 )}115 </div>116 <ConflictDialog buffer={active?.conflict ? active : null} />117 <ConfirmDialog118 open={!!closing}119 onOpenChange={(o) => !o && setClosing(null)}120 title="Discard unsaved changes?"121 description={closing ? `“${basename(closing.path)}” has changes that were not saved.` : ''}122 confirmLabel="Discard"123 danger124 onConfirm={() => { if (closing) close(closing.key); }}125 />126 </div>127 );128}129130function BufferView({ buffer: b, mode, mobile, fontSize, onChange, onSave }: { buffer: Buffer; mode: 'edit' | 'preview'; mobile?: boolean; fontSize: number; onChange: (v: string) => void; onSave: () => void }) {131 if (b.status === 'loading') return <div className="flex h-full items-center justify-center"><Spinner /></div>;132 if (b.status === 'error') return <EmptyState title="Could not open this file" description={b.error} className="h-full" />;133 if (b.status === 'binary') return <FileMeta buffer={b} reason="Binary file — not opened as text" />;134 if (b.status === 'too_large') return <FileMeta buffer={b} reason="File is larger than 2 MiB — not opened in the editor" />;135 if (b.status === 'preview') return isPdfPath(b.path) ? <PdfPreview buffer={b} /> : isImagePath(b.path) ? <ImagePreview buffer={b} /> : <FileMeta buffer={b} reason="No preview available" />;136 if (b.kind === 'diff') {137 if (b.rawDiff !== null) return <CodeEditor modelPath={`${b.key}.diff`} value={b.rawDiff || 'No changes.'} readOnly language="diff" mobile={mobile} fontSize={fontSize} />;138 return <DiffView original={b.original ?? ''} modified={b.content} modelPath={`${b.key}`} mobile={mobile} />;139 }140 if (mode === 'preview' && isMarkdownPath(b.path)) return <MarkdownPreview source={b.content} />;141 if (mode === 'preview' && isJsonPath(b.path)) return <JsonPretty source={b.content} />;142 return <CodeEditor modelPath={`${b.projectId}/${b.path}`} value={b.content} onChange={onChange} onSave={onSave} mobile={mobile} fontSize={fontSize} />;143}144145function ConflictDialog({ buffer }: { buffer: Buffer | null }) {146 const brand = useBrand();147 const { reloadTheirs, overwrite, openConflictDiff } = useEditor();148 const [busy, setBusy] = useState<string | null>(null);149 const [dismissed, setDismissed] = useState<string | null>(null);150 const open = !!buffer && dismissed !== `${buffer.key}:${buffer.conflictSeq}`;151 if (!buffer) return null;152 const run = async (name: string, fn: () => Promise<unknown>) => {153 setBusy(name);154 try {155 await fn();156 } finally {157 setBusy(null);158 }159 };160 return (161 <Dialog162 open={open}163 onOpenChange={(o) => !o && setDismissed(`${buffer.key}:${buffer.conflictSeq}`)}164 title="File changed on disk"165 description={166 <>167 <span className="font-mono text-text">{buffer.path}</span> was modified on the server after you opened it (for example by {brand.short} or a shell command). Your edits have <strong className="text-text">not</strong> been saved.168 </>169 }170 footer={171 <>172 <Button variant="ghost" loading={busy === 'diff'} onClick={() => run('diff', async () => { await openConflictDiff(buffer.key); setDismissed(`${buffer.key}:${buffer.conflictSeq}`); })} data-testid="conflict-diff">173 View diff174 </Button>175 <Button loading={busy === 'reload'} onClick={() => run('reload', () => reloadTheirs(buffer.key))} data-testid="conflict-reload">176 Reload theirs177 </Button>178 <Button variant="danger" loading={busy === 'overwrite'} onClick={() => run('overwrite', async () => { if ((await overwrite(buffer.key)) === 'saved') toast.success('Saved', 'Your version replaced the file on disk.'); })} data-testid="conflict-overwrite">179 Overwrite180 </Button>181 </>182 }183 >184 <ul className="list-disc space-y-1 pl-5 text-xs text-text-2">185 <li><strong className="text-text">Reload theirs</strong> discards your edits and loads the version on disk.</li>186 <li><strong className="text-text">Overwrite</strong> replaces the version on disk with your edits.</li>187 <li><strong className="text-text">View diff</strong> shows both versions side by side; nothing is written.</li>188 </ul>189 </Dialog>190 );191}192export { DownloadLink };193