SPB Git

Repository Public

editor-pane.tsx 11.0 KB 193 lines tsx

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
Files main
Menu

Theme