Leandro von Werra Claude Opus 5 commited on
Commit
01a64bf
·
unverified ·
1 Parent(s): dddaad3

Files pane: stop the phantom conflicts, keep the pane's place, wrap and prettify (#22)

Browse files

* Files pane: stop the phantom conflicts, keep the pane's place, wrap and format

Three things, all from using it.

**"Changed on disk" when nothing had changed.** The save precondition compared
mtime, and /data is a FUSE bucket mount that rewrites mtime when it syncs an
object — measured here drifting 5.8s on a file nobody touched, well past the 1s
tolerance. So every save eventually refused itself. The precondition is now the
file's content: preview hands out a short sha1 (`tag`) of the bytes it read, the
save carries it back as `base`, and the write is refused only if the bytes moved
on. Immune to whatever the mount does with timestamps, and it still catches the
case it exists for — an agent writing the file under an open buffer.

**The pane forgot everything when it went off screen.** Switching a tile to
another session unmounted it, which threw away the folder you were in, the file
you had open, and anything you had typed. State lives in filesMemory.ts now,
keyed by session: read on mount, written as it changes, mirrored to localStorage
so it also survives a reload of the whole app. Come back and the buffer is still
there, still marked unsaved, with the disk untouched.

**Wrap and Format.** JSON arrives from agents as one enormous line: Wrap turns it
into a paragraph (a reading preference, sticky across files and reloads), Format
re-indents it in the buffer and leaves saving to you. Format appears only for
.json you can edit, and says where the parse broke on a half-written one.

Two bugs found while testing, both mine:

- Adopting a kept buffer ran on every render, not once per file. ⌘S fires two
handlers (the editor's keymap and the pane's); the first cleared the buffer,
the render in between put it back with the tag from before the write, and the
second handler saved a stale base — so a file reported itself changed on disk
one moment after being saved. Restore is once per path now, and flush() is
single-flight so a double trigger lands on the same save.
- A failed Format set the file's status to 'error', which counts as unsaved: a
parse complaint made a clean file look dirty and put the unsaved-changes dialog
in the way of closing it. It has its own slot now.

Endpoint checks: preview carries a tag · save with a good tag after the mtime
jumps 37s → 200 (was 409) · save on a tag that moved → 409 · no base → 200 ·
a bogus mtime param with a good tag → 200.

Browser: switch away and back — file, buffer and dirty dot all survive, disk
clean; same after a full reload; ⌘S issues exactly one write; breadcrumb returns
to workspaces/ws/deep/inner instead of the root; Wrap stops a long line
overflowing and carries to the next file and across a reload; Format takes
packed.json from 1 line to 13 without touching disk, saves as valid JSON, and on
broken.json reports "Expected double-quoted property name in JSON at position 15"
while leaving the file alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Files pane: call it Prettify

"Format" doesn't say what it will do to your file — it could as easily mean
reformat, convert, or overwrite. Prettify is what this operation is called
everywhere JSON is handled, so it needs no explaining.

Renamed through the handle too (prettify / prettifyError), so the code and the
button agree. Behaviour is untouched: still .json only, still edits the buffer
and leaves saving to you, still reports where the parse broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

server/src/index.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
  import fs from 'node:fs';
5
  import { URL, fileURLToPath } from 'node:url';
6
  import { execFile } from 'node:child_process';
 
7
  import express from 'express';
8
  import { WebSocketServer } from 'ws';
9
  import {
@@ -1324,6 +1325,9 @@ app.get('/api/files/:id/preview', async (req, res) => {
1324
  const meta = {
1325
  path: path.relative(root, f), name: path.basename(f), size: st.size, mtime: st.mtimeMs,
1326
  kind, mime: mimeOf(f, kind === 'trace' ? 'text' : kind), harness,
 
 
 
1327
  };
1328
  // A trace still carries its raw text, so the Source toggle has something to
1329
  // show without a second round trip.
@@ -1372,10 +1376,10 @@ app.get('/api/files/:id/download', (req, res) => {
1372
 
1373
  // Save an edited text file.
1374
  //
1375
- // Two guards earn their keep here. First, `mtime`: agents are writing these very
1376
- // files while a tab sits open on one, so a save carries the mtime the editor
1377
- // loaded and is refused if the file moved on — losing an agent's work to a
1378
- // stale buffer is worse than making someone reload. Second, only files we could
1379
  // show WHOLE are writable: the preview serves the first 512 KB of a big file, and
1380
  // saving that back would silently truncate the rest.
1381
  app.put('/api/files/:id/write', express.text({ limit: '8mb', type: '*/*' }), (req, res) => {
@@ -1393,8 +1397,8 @@ app.put('/api/files/:id/write', express.text({ limit: '8mb', type: '*/*' }), (re
1393
  if (st.size > TEXT_MAX) {
1394
  return res.status(413).json({ error: 'too big to edit — only the first part was loaded' });
1395
  }
1396
- const expected = Number(req.query.mtime || 0);
1397
- if (expected && Math.abs(st.mtimeMs - expected) > 1000) {
1398
  return res.status(409).json({ error: 'changed on disk since you opened it', mtime: st.mtimeMs });
1399
  }
1400
  const text = typeof req.body === 'string' ? req.body : '';
@@ -1411,9 +1415,22 @@ app.put('/api/files/:id/write', express.text({ limit: '8mb', type: '*/*' }), (re
1411
  return res.status(500).json({ error: String((e && e.message) || e) });
1412
  }
1413
  const after = fs.statSync(f);
1414
- res.json({ ok: true, size: after.size, mtime: after.mtimeMs });
1415
  });
1416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1417
  // Folders something else depends on: an agent's own workspace and the shared
1418
  // skills dir. Renaming or deleting those out from under a running agent breaks
1419
  // its cwd, so the pane refuses. Returns a label to say WHY, or null if it's fair
 
4
  import fs from 'node:fs';
5
  import { URL, fileURLToPath } from 'node:url';
6
  import { execFile } from 'node:child_process';
7
+ import crypto from 'node:crypto';
8
  import express from 'express';
9
  import { WebSocketServer } from 'ws';
10
  import {
 
1325
  const meta = {
1326
  path: path.relative(root, f), name: path.basename(f), size: st.size, mtime: st.mtimeMs,
1327
  kind, mime: mimeOf(f, kind === 'trace' ? 'text' : kind), harness,
1328
+ // Only for files small enough to edit — which is exactly the set that can be
1329
+ // saved, so nothing is hashed that could never be written back.
1330
+ tag: st.size <= TEXT_MAX ? contentTag(f) : null,
1331
  };
1332
  // A trace still carries its raw text, so the Source toggle has something to
1333
  // show without a second round trip.
 
1376
 
1377
  // Save an edited text file.
1378
  //
1379
+ // Two guards earn their keep here. First, `base`: agents are writing these very
1380
+ // files while a tab sits open on one, so a save carries the content tag the
1381
+ // editor loaded and is refused if the file moved on — losing an agent's work to
1382
+ // a stale buffer is worse than making someone reload. Second, only files we could
1383
  // show WHOLE are writable: the preview serves the first 512 KB of a big file, and
1384
  // saving that back would silently truncate the rest.
1385
  app.put('/api/files/:id/write', express.text({ limit: '8mb', type: '*/*' }), (req, res) => {
 
1397
  if (st.size > TEXT_MAX) {
1398
  return res.status(413).json({ error: 'too big to edit — only the first part was loaded' });
1399
  }
1400
+ const base = String(req.query.base || '');
1401
+ if (base && base !== contentTag(f)) {
1402
  return res.status(409).json({ error: 'changed on disk since you opened it', mtime: st.mtimeMs });
1403
  }
1404
  const text = typeof req.body === 'string' ? req.body : '';
 
1415
  return res.status(500).json({ error: String((e && e.message) || e) });
1416
  }
1417
  const after = fs.statSync(f);
1418
+ res.json({ ok: true, size: after.size, mtime: after.mtimeMs, tag: contentTag(f) });
1419
  });
1420
 
1421
+ // What the editor's save is checked against. NOT mtime: /data is a FUSE bucket
1422
+ // mount that rewrites a file's mtime when it syncs the object — measured drifting
1423
+ // 5.8s with nobody touching the file — so an mtime precondition refuses honest
1424
+ // saves all day. The content is the thing we actually care about: same bytes as
1425
+ // when the editor loaded means nobody else got in.
1426
+ function contentTag(file) {
1427
+ try {
1428
+ return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 16);
1429
+ } catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+
1434
  // Folders something else depends on: an agent's own workspace and the shared
1435
  // skills dir. Renaming or deleting those out from under a running agent breaks
1436
  // its cwd, so the pane refuses. Returns a label to say WHY, or null if it's fair
web/src/api.ts CHANGED
@@ -165,6 +165,8 @@ export interface FileEntry { name: string; dir: boolean; size: number; mtime: nu
165
  export interface FileListing { path: string; root: string; entries: FileEntry[]; }
166
  export interface FilePreview {
167
  path: string; name: string; size: number; mtime: number; kind: FileKind; mime: string;
 
 
168
  text?: string; truncated?: boolean; reason?: string;
169
  /** kind==='trace': which harness wrote it (claude, codex, …). */
170
  harness?: string | null;
@@ -223,10 +225,13 @@ export const deleteEntry = (id: string, p: string) =>
223
  export const downloadUrl = (id: string, p: string) =>
224
  `/api/files/${id}/download?path=${encodeURIComponent(p)}`;
225
 
226
- // Save an edited text file. `mtime` is the one the editor loaded: the server
227
- // refuses the write if the file changed underneath (an agent edited it too).
228
- export const writeFile = async (id: string, p: string, text: string, mtime: number): Promise<{ size: number; mtime: number }> => {
229
- const r = await fetch(`/api/files/${id}/write?path=${encodeURIComponent(p)}&mtime=${mtime}`, {
 
 
 
230
  method: 'PUT', headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text,
231
  });
232
  // Unlike the rest of the API, a failed save has something worth reading in it
 
165
  export interface FileListing { path: string; root: string; entries: FileEntry[]; }
166
  export interface FilePreview {
167
  path: string; name: string; size: number; mtime: number; kind: FileKind; mime: string;
168
+ /** Content tag a save is checked against — null for files too big to edit. */
169
+ tag?: string | null;
170
  text?: string; truncated?: boolean; reason?: string;
171
  /** kind==='trace': which harness wrote it (claude, codex, …). */
172
  harness?: string | null;
 
225
  export const downloadUrl = (id: string, p: string) =>
226
  `/api/files/${id}/download?path=${encodeURIComponent(p)}`;
227
 
228
+ // Save an edited text file. `base` is the content tag the editor loaded: the
229
+ // server refuses the write if the file's bytes moved on since (an agent edited
230
+ // it too). Deliberately NOT mtime the bucket mount rewrites that on its own
231
+ // when it syncs an object, which made every save look like a conflict.
232
+ export const writeFile = async (id: string, p: string, text: string, base: string | null): Promise<{ size: number; mtime: number; tag: string | null }> => {
233
+ const q = base ? `&base=${encodeURIComponent(base)}` : '';
234
+ const r = await fetch(`/api/files/${id}/write?path=${encodeURIComponent(p)}${q}`, {
235
  method: 'PUT', headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text,
236
  });
237
  // Unlike the rest of the API, a failed save has something worth reading in it
web/src/components/CodeView.tsx CHANGED
@@ -41,9 +41,11 @@ export type CodeViewProps = {
41
  onSave?: () => void;
42
  /** Light/dark, so the editor's own chrome matches the app. */
43
  theme: 'light' | 'dark';
 
 
44
  };
45
 
46
- export default function CodeView({ text, name, editable, onChange, onSave, theme }: CodeViewProps) {
47
  const host = useRef<HTMLDivElement | null>(null);
48
  const view = useRef<any>(null);
49
  const core = useRef<CM | null>(null);
@@ -68,6 +70,7 @@ export default function CodeView({ text, name, editable, onChange, onSave, theme
68
  doc: text,
69
  name,
70
  editable,
 
71
  theme,
72
  onChange: (v: string) => cbs.current.onChange?.(v),
73
  onSave: () => cbs.current.onSave?.(),
@@ -92,6 +95,7 @@ export default function CodeView({ text, name, editable, onChange, onSave, theme
92
  }, [text]);
93
 
94
  useEffect(() => { if (view.current) core.current?.setEditable(view.current, editable); }, [editable]);
 
95
  useEffect(() => { if (view.current) core.current?.setTheme(view.current, theme); }, [theme]);
96
 
97
  return (
 
41
  onSave?: () => void;
42
  /** Light/dark, so the editor's own chrome matches the app. */
43
  theme: 'light' | 'dark';
44
+ /** Soft-wrap long lines instead of scrolling sideways. */
45
+ wrap?: boolean;
46
  };
47
 
48
+ export default function CodeView({ text, name, editable, onChange, onSave, theme, wrap = false }: CodeViewProps) {
49
  const host = useRef<HTMLDivElement | null>(null);
50
  const view = useRef<any>(null);
51
  const core = useRef<CM | null>(null);
 
70
  doc: text,
71
  name,
72
  editable,
73
+ wrap,
74
  theme,
75
  onChange: (v: string) => cbs.current.onChange?.(v),
76
  onSave: () => cbs.current.onSave?.(),
 
95
  }, [text]);
96
 
97
  useEffect(() => { if (view.current) core.current?.setEditable(view.current, editable); }, [editable]);
98
+ useEffect(() => { if (view.current) core.current?.setWrap(view.current, wrap); }, [wrap]);
99
  useEffect(() => { if (view.current) core.current?.setTheme(view.current, theme); }, [theme]);
100
 
101
  return (
web/src/components/FilesPane.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
2
  import type { Session } from '../types';
3
  import * as api from '../api';
4
  import type { FileEntry, FileKind, FilePreview } from '../api';
@@ -467,7 +468,11 @@ function useTheme(): 'light' | 'dark' {
467
  return theme;
468
  }
469
 
470
- type ViewInfo = { meta: FilePreview | null; extra: string[]; edit?: SaveState; trace?: TraceInfo };
 
 
 
 
471
 
472
  // What a rendered trace needs from the pane's info strip: the same chips and
473
  // prompt navigation the Trace pane puts in its own header.
@@ -500,6 +505,13 @@ export type SaveState = {
500
  conflict: boolean;
501
  reload: () => void;
502
  overwrite: () => void;
 
 
 
 
 
 
 
503
  };
504
 
505
  // The viewer for one file. Kinds it can't render fall back to an honest
@@ -522,19 +534,42 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
522
 
523
  // Editing state. `draft` is null until the first keystroke: the editor is
524
  // always writable, but a file nobody touched has nothing to save.
525
- const [draft, setDraft] = useState<string | null>(null);
526
- const [status, setStatus] = useState<SaveState['status']>('clean');
 
 
 
 
 
 
 
527
  const [saveErr, setSaveErr] = useState<string | null>(null);
528
  const [conflict, setConflict] = useState(false);
529
  // The buffer waiting to be written, if any. There is no timer: these files
530
  // have no undo and no git behind them, so nothing reaches disk until it is
531
  // asked for.
532
- const pending = useRef<{ text: string; mtime: number } | null>(null);
 
 
 
 
 
 
 
 
 
 
 
533
 
534
  useEffect(() => {
535
  let alive = true;
536
  setMeta(null); setErr(null); setSource(null); setDims(null); setPages(null);
537
- setDraft(null); setSaveErr(null); setConflict(false); setStatus('clean');
 
 
 
 
 
538
  setTraceHead(null); setTraceQuery('');
539
  api.previewFile(sessionId, path)
540
  .then((m) => { if (alive) setMeta(m); })
@@ -597,15 +632,21 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
597
  // Resolves true when the file on disk matches the buffer — which "save and
598
  // close" needs, so a failed write keeps the dialog up instead of closing over
599
  // the error.
600
- const flush = useCallback(async (force = false): Promise<boolean> => {
 
 
 
 
601
  const job = pending.current;
602
- if (!job) return true; // nothing outstanding
603
  pending.current = null;
604
  setStatus('saving'); setSaveErr(null);
 
605
  try {
606
- const after = await api.writeFile(sessionId, path, job.text, force ? 0 : job.mtime);
607
  setConflict(false);
608
- setMeta((m) => (m ? { ...m, text: m.kind === 'html' ? m.text : job.text, size: after.size, mtime: after.mtime } : m));
 
609
  if (kindRef.current === 'html') setSource(job.text);
610
  setStatus('saved');
611
  onSaved?.();
@@ -620,6 +661,10 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
620
  setStatus('error');
621
  return false;
622
  }
 
 
 
 
623
  }, [sessionId, path, onSaved]);
624
 
625
  // Typing only fills the buffer. Writing it is a decision, taken with the Save
@@ -628,9 +673,13 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
628
  const onEdit = useCallback((next: string) => {
629
  setDraft(next);
630
  if (!canEdit || !meta) return;
631
- pending.current = { text: next, mtime: meta.mtime };
 
 
 
 
632
  setStatus((st) => (st === 'error' ? st : 'dirty')); // keep a failure visible
633
- }, [canEdit, meta]);
634
 
635
  // Switching files abandons an unsaved buffer, so the pane asks before it lets
636
  // that happen (see leaveView).
@@ -642,12 +691,34 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
642
  return () => clearTimeout(t);
643
  }, [status]);
644
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  const edit = useMemo<SaveState>(() => ({
646
  can: canEdit,
647
  save: () => flush(),
648
  saveNow: (force = false) => flush(force),
649
  discard: () => {
650
  pending.current = null;
 
651
  setDraft(null); setSaveErr(null); setStatus('clean');
652
  },
653
  // Only worth saying when editing was plausible and isn't: nobody expects to
@@ -658,6 +729,7 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
658
  conflict,
659
  reload: () => {
660
  pending.current = null;
 
661
  setDraft(null); setSaveErr(null); setConflict(false); setStatus('clean');
662
  setTraceHead(null); setTraceQuery('');
663
  setMeta(null);
@@ -667,7 +739,12 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
667
  }
668
  },
669
  overwrite: () => flush(true),
670
- }), [canEdit, editKind, meta?.truncated, status, saveErr, conflict, flush, sessionId, path]);
 
 
 
 
 
671
 
672
  const traceInfo = useMemo<TraceInfo | undefined>(() => (meta?.kind === 'trace' && !raw ? {
673
  harnessLabel: traceHead?.harnessLabel,
@@ -682,7 +759,12 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
682
  const traceSrc = useCallback<TraceSource>(
683
  (offset, limit) => api.getFileTracePage(sessionId, path, offset, limit), [sessionId, path]);
684
 
685
- useEffect(() => { onInfo({ meta, extra, edit, trace: traceInfo }); }, [meta, extra, edit, traceInfo, onInfo]);
 
 
 
 
 
686
 
687
  if (err) return <div className="fv-empty">Could not open this file.<div className="fv-sub">{err}</div></div>;
688
  if (!meta) return <div className="fv-empty">Loading…</div>;
@@ -691,6 +773,7 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
691
  const code = (text: string) => (
692
  <CodeView
693
  text={text} name={meta.name} theme={theme}
 
694
  editable={canEdit}
695
  onChange={onEdit}
696
  onSave={() => flush()} // ⌘S still works; it just beats the timer
@@ -791,13 +874,15 @@ export default function FilesPane({
791
  onFocus?: () => void;
792
  onClose: () => void;
793
  }) {
794
- const [root, setRoot] = useState('');
 
 
795
  const [rootLabel, setRootLabel] = useState('workspace');
796
  const [busy, setBusy] = useState(false);
797
  const [dragOver, setDragOver] = useState(false);
798
  const [reloadKey, setReloadKey] = useState(0);
799
- const [sort, setSort] = useState<Sort>(DEFAULT_SORT);
800
- const [viewing, setViewing] = useState<string | null>(null);
801
  const [info, setInfo] = useState<ViewInfo>({ meta: null, extra: [] });
802
  const [raw, setRaw] = useState(false); // markdown/html: show the source
803
  const [scripts, setScripts] = useState(false); // html: run the page's own JS
@@ -809,13 +894,20 @@ export default function FilesPane({
809
  const [moving, setMoving] = useState<Moving | null>(null);
810
  // Where new folders, new files and uploads land: the folder you last clicked,
811
  // falling back to the one the breadcrumb names.
812
- const [target, setTarget] = useState<string | null>(null);
813
  const [acting, setActing] = useState(false);
814
  const [actErr, setActErr] = useState<string | null>(null);
815
  const paneRef = useRef<HTMLDivElement | null>(null);
816
 
817
  const dir = useDir(session.id, root, reloadKey);
818
 
 
 
 
 
 
 
 
819
  useEffect(() => { api.listFiles(session.id, '').then((r) => setRootLabel(r.root)).catch(() => {}); }, [session.id]);
820
  // Entering a preview focuses the pane, so Esc walks back out without a
821
  // window-wide key handler stealing keys from the other panes. Every file opens
@@ -1051,6 +1143,25 @@ export default function FilesPane({
1051
  )}
1052
  </span>
1053
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  {edit && !edit.can && edit.why && (
1055
  <span className="fi-stat fi-extra" title={edit.why}>read-only</span>
1056
  )}
 
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { recall, remember, readWrap, writeWrap } from './filesMemory';
3
  import type { Session } from '../types';
4
  import * as api from '../api';
5
  import type { FileEntry, FileKind, FilePreview } from '../api';
 
468
  return theme;
469
  }
470
 
471
+ type ViewInfo = {
472
+ meta: FilePreview | null; extra: string[]; edit?: SaveState; trace?: TraceInfo;
473
+ /** True when a text surface is on screen, so wrapping means something. */
474
+ showWrap?: boolean;
475
+ };
476
 
477
  // What a rendered trace needs from the pane's info strip: the same chips and
478
  // prompt navigation the Trace pane puts in its own header.
 
505
  conflict: boolean;
506
  reload: () => void;
507
  overwrite: () => void;
508
+ /** Soft-wrap long lines — a reading preference, sticky across files. */
509
+ wrap: boolean;
510
+ setWrap: (on: boolean) => void;
511
+ /** Re-indent JSON in the buffer. Absent unless the open file is JSON. */
512
+ prettify?: () => void;
513
+ /** Why the last Prettify didn't happen. Not a save failure — nothing is dirty. */
514
+ prettifyError?: string | null;
515
  };
516
 
517
  // The viewer for one file. Kinds it can't render fall back to an honest
 
534
 
535
  // Editing state. `draft` is null until the first keystroke: the editor is
536
  // always writable, but a file nobody touched has nothing to save.
537
+ const [wrap, setWrapPref] = useState(readWrap);
538
+ const [draft, setDraft] = useState<string | null>(() => {
539
+ const kept = recall(sessionId).draft;
540
+ return kept && kept.path === path ? kept.text : null;
541
+ });
542
+ const [status, setStatus] = useState<SaveState['status']>(() => {
543
+ const kept = recall(sessionId).draft;
544
+ return kept && kept.path === path ? 'dirty' : 'clean';
545
+ });
546
  const [saveErr, setSaveErr] = useState<string | null>(null);
547
  const [conflict, setConflict] = useState(false);
548
  // The buffer waiting to be written, if any. There is no timer: these files
549
  // have no undo and no git behind them, so nothing reaches disk until it is
550
  // asked for.
551
+ const pending = useRef<{ text: string; base: string | null } | null>(null);
552
+ // Adopt a kept buffer ONCE per file. Re-checking on every render resurrected
553
+ // it mid-save — ⌘S fires two handlers (the editor's keymap and the pane's), the
554
+ // first cleared `pending` and the render in between put it back with the tag it
555
+ // had before the write, so the second handler saved a stale base and the file
556
+ // reported itself changed on disk one moment after being saved.
557
+ const restoredFor = useRef<string | null>(null);
558
+ if (restoredFor.current !== path) {
559
+ restoredFor.current = path;
560
+ const kept = recall(sessionId).draft;
561
+ pending.current = kept && kept.path === path ? { text: kept.text, base: kept.base } : null;
562
+ }
563
 
564
  useEffect(() => {
565
  let alive = true;
566
  setMeta(null); setErr(null); setSource(null); setDims(null); setPages(null);
567
+ setSaveErr(null); setConflict(false); setFmtErr(null);
568
+ // A buffer kept across a pane switch survives the reload of its own file —
569
+ // dropping it here is exactly the loss this is meant to prevent.
570
+ const kept = recall(sessionId).draft;
571
+ if (kept && kept.path === path) { setDraft(kept.text); setStatus('dirty'); }
572
+ else { setDraft(null); setStatus('clean'); }
573
  setTraceHead(null); setTraceQuery('');
574
  api.previewFile(sessionId, path)
575
  .then((m) => { if (alive) setMeta(m); })
 
632
  // Resolves true when the file on disk matches the buffer — which "save and
633
  // close" needs, so a failed write keeps the dialog up instead of closing over
634
  // the error.
635
+ const inflight = useRef<Promise<boolean> | null>(null);
636
+ const flush = useCallback((force = false): Promise<boolean> => {
637
+ // One write at a time. Two callers land on the same save rather than racing
638
+ // each other into a conflict of their own making.
639
+ if (inflight.current) return inflight.current;
640
  const job = pending.current;
641
+ if (!job) return Promise.resolve(true); // nothing outstanding
642
  pending.current = null;
643
  setStatus('saving'); setSaveErr(null);
644
+ const run = async (): Promise<boolean> => {
645
  try {
646
+ const after = await api.writeFile(sessionId, path, job.text, force ? null : job.base);
647
  setConflict(false);
648
+ setMeta((m) => (m ? { ...m, text: m.kind === 'html' ? m.text : job.text, size: after.size, mtime: after.mtime, tag: after.tag } : m));
649
+ remember(sessionId, { draft: null });
650
  if (kindRef.current === 'html') setSource(job.text);
651
  setStatus('saved');
652
  onSaved?.();
 
661
  setStatus('error');
662
  return false;
663
  }
664
+ };
665
+ const p = run().finally(() => { inflight.current = null; });
666
+ inflight.current = p;
667
+ return p;
668
  }, [sessionId, path, onSaved]);
669
 
670
  // Typing only fills the buffer. Writing it is a decision, taken with the Save
 
673
  const onEdit = useCallback((next: string) => {
674
  setDraft(next);
675
  if (!canEdit || !meta) return;
676
+ const base = meta.tag ?? null;
677
+ pending.current = { text: next, base };
678
+ // Held outside the component so switching this tile to another session — or
679
+ // reloading the app — doesn't take the buffer with it.
680
+ remember(sessionId, { draft: { path, text: next, base } });
681
  setStatus((st) => (st === 'error' ? st : 'dirty')); // keep a failure visible
682
+ }, [canEdit, meta, sessionId, path]);
683
 
684
  // Switching files abandons an unsaved buffer, so the pane asks before it lets
685
  // that happen (see leaveView).
 
691
  return () => clearTimeout(t);
692
  }, [status]);
693
 
694
+ // JSON arrives from agents as one enormous line more often than not, which is
695
+ // unreadable either way: wrap turns it into a paragraph, Prettify gives it
696
+ // structure. Both are offered; neither writes anything on its own.
697
+ const isJson = /\.json$/i.test(meta?.name || '');
698
+ // Kept apart from saveErr on purpose: "this isn't valid JSON" is a complaint
699
+ // about a button press, not an unsaved buffer, and must not make the file look
700
+ // dirty or stand in the way of closing it.
701
+ const [fmtErr, setFmtErr] = useState<string | null>(null);
702
+ const prettify = useCallback(() => {
703
+ const src = draft ?? saved;
704
+ try {
705
+ const next = `${JSON.stringify(JSON.parse(src), null, 2)}\n`;
706
+ if (next !== src) onEdit(next);
707
+ setFmtErr(null);
708
+ } catch (e: any) {
709
+ // Say where it broke — "Expected double-quoted property name at position
710
+ // 15" is the useful half of this feature when a file is half-written.
711
+ setFmtErr(`not valid JSON — ${String(e?.message || e).replace(/^JSON\.parse: /, '')}`);
712
+ }
713
+ }, [draft, saved, onEdit]);
714
+
715
  const edit = useMemo<SaveState>(() => ({
716
  can: canEdit,
717
  save: () => flush(),
718
  saveNow: (force = false) => flush(force),
719
  discard: () => {
720
  pending.current = null;
721
+ remember(sessionId, { draft: null });
722
  setDraft(null); setSaveErr(null); setStatus('clean');
723
  },
724
  // Only worth saying when editing was plausible and isn't: nobody expects to
 
729
  conflict,
730
  reload: () => {
731
  pending.current = null;
732
+ remember(sessionId, { draft: null });
733
  setDraft(null); setSaveErr(null); setConflict(false); setStatus('clean');
734
  setTraceHead(null); setTraceQuery('');
735
  setMeta(null);
 
739
  }
740
  },
741
  overwrite: () => flush(true),
742
+ wrap,
743
+ setWrap: (on: boolean) => { setWrapPref(on); writeWrap(on); },
744
+ prettify: isJson && canEdit ? prettify : undefined,
745
+ prettifyError: fmtErr,
746
+ }), [canEdit, editKind, meta?.truncated, status, saveErr, conflict, flush, sessionId, path,
747
+ wrap, isJson, prettify, fmtErr]);
748
 
749
  const traceInfo = useMemo<TraceInfo | undefined>(() => (meta?.kind === 'trace' && !raw ? {
750
  harnessLabel: traceHead?.harnessLabel,
 
759
  const traceSrc = useCallback<TraceSource>(
760
  (offset, limit) => api.getFileTracePage(sessionId, path, offset, limit), [sessionId, path]);
761
 
762
+ // Rendered markdown and a rendered trace do their own wrapping; the toggle is
763
+ // for the surfaces that actually scroll sideways.
764
+ const showWrap = !!meta && (meta.kind === 'text'
765
+ || ((meta.kind === 'markdown' || meta.kind === 'html' || meta.kind === 'trace') && raw));
766
+ useEffect(() => { onInfo({ meta, extra, edit, trace: traceInfo, showWrap }); },
767
+ [meta, extra, edit, traceInfo, showWrap, onInfo]);
768
 
769
  if (err) return <div className="fv-empty">Could not open this file.<div className="fv-sub">{err}</div></div>;
770
  if (!meta) return <div className="fv-empty">Loading…</div>;
 
773
  const code = (text: string) => (
774
  <CodeView
775
  text={text} name={meta.name} theme={theme}
776
+ wrap={wrap}
777
  editable={canEdit}
778
  onChange={onEdit}
779
  onSave={() => flush()} // ⌘S still works; it just beats the timer
 
874
  onFocus?: () => void;
875
  onClose: () => void;
876
  }) {
877
+ // Where this pane was when it last went off screen. Read once, at mount.
878
+ const kept = useMemo(() => recall(session.id), [session.id]);
879
+ const [root, setRoot] = useState(kept.root);
880
  const [rootLabel, setRootLabel] = useState('workspace');
881
  const [busy, setBusy] = useState(false);
882
  const [dragOver, setDragOver] = useState(false);
883
  const [reloadKey, setReloadKey] = useState(0);
884
+ const [sort, setSort] = useState<Sort>(kept.sort ?? DEFAULT_SORT);
885
+ const [viewing, setViewing] = useState<string | null>(kept.viewing);
886
  const [info, setInfo] = useState<ViewInfo>({ meta: null, extra: [] });
887
  const [raw, setRaw] = useState(false); // markdown/html: show the source
888
  const [scripts, setScripts] = useState(false); // html: run the page's own JS
 
894
  const [moving, setMoving] = useState<Moving | null>(null);
895
  // Where new folders, new files and uploads land: the folder you last clicked,
896
  // falling back to the one the breadcrumb names.
897
+ const [target, setTarget] = useState<string | null>(kept.target);
898
  const [acting, setActing] = useState(false);
899
  const [actErr, setActErr] = useState<string | null>(null);
900
  const paneRef = useRef<HTMLDivElement | null>(null);
901
 
902
  const dir = useDir(session.id, root, reloadKey);
903
 
904
+ // Coming back to a pane should put you where you left it, not at the top of
905
+ // the workspace — the folder you were in, the file you were reading, and the
906
+ // way you had it sorted.
907
+ useEffect(() => {
908
+ remember(session.id, { root, viewing, target, sort });
909
+ }, [session.id, root, viewing, target, sort]);
910
+
911
  useEffect(() => { api.listFiles(session.id, '').then((r) => setRootLabel(r.root)).catch(() => {}); }, [session.id]);
912
  // Entering a preview focuses the pane, so Esc walks back out without a
913
  // window-wide key handler stealing keys from the other panes. Every file opens
 
1143
  )}
1144
  </span>
1145
  ) : null}
1146
+ {edit?.prettifyError && <span className="fi-err" title={edit.prettifyError}>{edit.prettifyError}</span>}
1147
+ {edit?.can && edit.prettify && (
1148
+ <button
1149
+ className="mini-btn" onClick={edit.prettify}
1150
+ title="Re-indent this JSON in the buffer — it still needs saving"
1151
+ >
1152
+ Prettify
1153
+ </button>
1154
+ )}
1155
+ {info.showWrap && edit && (
1156
+ <button
1157
+ className={`mini-btn${edit.wrap ? ' on' : ''}`}
1158
+ onClick={() => edit.setWrap(!edit.wrap)}
1159
+ title={edit.wrap ? 'Long lines are wrapped — click to let them run' : 'Wrap long lines'}
1160
+ aria-pressed={edit.wrap}
1161
+ >
1162
+ Wrap
1163
+ </button>
1164
+ )}
1165
  {edit && !edit.can && edit.why && (
1166
  <span className="fi-stat fi-extra" title={edit.why}>read-only</span>
1167
  )}
web/src/components/cm-core.ts CHANGED
@@ -132,6 +132,7 @@ async function languageFor(name: string): Promise<Extension | null> {
132
  const langComp = new Compartment();
133
  const editComp = new Compartment();
134
  const themeComp = new Compartment();
 
135
 
136
  export type EditorHandle = EditorView & { __compartments?: never };
137
 
@@ -140,6 +141,7 @@ export function createEditor(opts: {
140
  doc: string;
141
  name: string;
142
  editable: boolean;
 
143
  theme: 'light' | 'dark';
144
  onChange: (next: string) => void;
145
  onSave: () => void;
@@ -153,6 +155,7 @@ export function createEditor(opts: {
153
  const state = EditorState.create({
154
  doc: opts.doc,
155
  extensions: [
 
156
  lineNumbers(),
157
  foldGutter(),
158
  highlightSpecialChars(),
@@ -200,6 +203,10 @@ export function setDoc(view: EditorView, text: string) {
200
  }
201
  }
202
 
 
 
 
 
203
  export function setEditable(view: EditorView, editable: boolean) {
204
  view.dispatch({
205
  effects: editComp.reconfigure([EditorView.editable.of(editable), EditorState.readOnly.of(!editable)]),
 
132
  const langComp = new Compartment();
133
  const editComp = new Compartment();
134
  const themeComp = new Compartment();
135
+ const wrapComp = new Compartment();
136
 
137
  export type EditorHandle = EditorView & { __compartments?: never };
138
 
 
141
  doc: string;
142
  name: string;
143
  editable: boolean;
144
+ wrap: boolean;
145
  theme: 'light' | 'dark';
146
  onChange: (next: string) => void;
147
  onSave: () => void;
 
155
  const state = EditorState.create({
156
  doc: opts.doc,
157
  extensions: [
158
+ wrapComp.of(opts.wrap ? EditorView.lineWrapping : []),
159
  lineNumbers(),
160
  foldGutter(),
161
  highlightSpecialChars(),
 
203
  }
204
  }
205
 
206
+ export function setWrap(view: EditorView, wrap: boolean) {
207
+ view.dispatch({ effects: wrapComp.reconfigure(wrap ? EditorView.lineWrapping : []) });
208
+ }
209
+
210
  export function setEditable(view: EditorView, editable: boolean) {
211
  view.dispatch({
212
  effects: editComp.reconfigure([EditorView.editable.of(editable), EditorState.readOnly.of(!editable)]),
web/src/components/filesMemory.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What a Files pane remembers when it isn't on screen.
2
+ //
3
+ // The pane unmounts whenever its tile shows something else, so without this
4
+ // every switch away threw out where you were AND anything you had typed — you
5
+ // came back to the workspace root with an empty editor. State lives here
6
+ // instead, keyed by session, and the pane reads it back on mount.
7
+ //
8
+ // The in-memory map is what makes a pane switch lossless. localStorage is the
9
+ // second line: it also survives a reload of the whole app, which is the other
10
+ // way people lose a buffer they never asked to lose.
11
+
12
+ export interface Draft {
13
+ path: string;
14
+ text: string;
15
+ /** Content tag the buffer was based on — see contentTag() on the server. */
16
+ base: string | null;
17
+ }
18
+
19
+ export interface FilesMemory {
20
+ root: string;
21
+ viewing: string | null;
22
+ target: string | null;
23
+ sort?: { key: 'name' | 'size' | 'time'; desc: boolean };
24
+ draft: Draft | null;
25
+ }
26
+
27
+ const EMPTY: FilesMemory = { root: '', viewing: null, target: null, draft: null };
28
+
29
+ // A buffer big enough to be worth keeping is still small next to the 5 MB
30
+ // localStorage budget; past this the in-memory copy alone carries it, so a pane
31
+ // switch is still safe and only a full reload loses it.
32
+ const DRAFT_MAX = 256 * 1024;
33
+
34
+ const mem = new Map<string, FilesMemory>();
35
+ const key = (id: string) => `am.files.${id}`;
36
+
37
+ export function recall(id: string): FilesMemory {
38
+ const held = mem.get(id);
39
+ if (held) return held;
40
+ try {
41
+ const raw = localStorage.getItem(key(id));
42
+ if (raw) {
43
+ const parsed = { ...EMPTY, ...JSON.parse(raw) } as FilesMemory;
44
+ mem.set(id, parsed);
45
+ return parsed;
46
+ }
47
+ } catch {
48
+ // corrupt or unavailable storage is not worth failing a pane over
49
+ }
50
+ return EMPTY;
51
+ }
52
+
53
+ export function remember(id: string, patch: Partial<FilesMemory>) {
54
+ const next = { ...recall(id), ...patch };
55
+ mem.set(id, next);
56
+ try {
57
+ const forDisk = next.draft && next.draft.text.length > DRAFT_MAX ? { ...next, draft: null } : next;
58
+ localStorage.setItem(key(id), JSON.stringify(forDisk));
59
+ } catch {
60
+ // over quota, or private mode: memory still has it
61
+ }
62
+ }
63
+
64
+ export function forget(id: string) {
65
+ mem.delete(id);
66
+ try { localStorage.removeItem(key(id)); } catch {}
67
+ }
68
+
69
+ // One sticky preference rather than a per-file one: wrapping is about how you
70
+ // like to read, not about the file in front of you.
71
+ const WRAP = 'am.files.wrap';
72
+ export const readWrap = (): boolean => {
73
+ try { return localStorage.getItem(WRAP) === '1'; } catch { return false; }
74
+ };
75
+ export const writeWrap = (on: boolean) => {
76
+ try { localStorage.setItem(WRAP, on ? '1' : '0'); } catch {}
77
+ };
web/src/styles.css CHANGED
@@ -722,6 +722,7 @@ body {
722
  .mini-btn.danger { background: var(--danger); color: #fff; border-color: transparent; }
723
  .mini-btn.danger:disabled { opacity: .5; }
724
  /* the one obvious action in a group — Create, Save and close */
 
725
  .mini-btn.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
726
  .mini-btn.primary:disabled { opacity: .45; }
727
  .tree-row.selected { background: color-mix(in srgb, var(--accent) 14%, transparent); }
 
722
  .mini-btn.danger { background: var(--danger); color: #fff; border-color: transparent; }
723
  .mini-btn.danger:disabled { opacity: .5; }
724
  /* the one obvious action in a group — Create, Save and close */
725
+ .mini-btn.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
726
  .mini-btn.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
727
  .mini-btn.primary:disabled { opacity: .45; }
728
  .tree-row.selected { background: color-mix(in srgb, var(--accent) 14%, transparent); }