Spaces:
Running
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 +24 -7
- web/src/api.ts +9 -4
- web/src/components/CodeView.tsx +5 -1
- web/src/components/FilesPane.tsx +128 -17
- web/src/components/cm-core.ts +7 -0
- web/src/components/filesMemory.ts +77 -0
- web/src/styles.css +1 -0
|
@@ -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, `
|
| 1376 |
-
// files while a tab sits open on one, so a save carries the
|
| 1377 |
-
// loaded and is refused if the file moved on — losing an agent's work to
|
| 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
|
| 1397 |
-
if (
|
| 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
|
|
@@ -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. `
|
| 227 |
-
// refuses the write if the file
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 (
|
|
@@ -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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 526 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
useEffect(() => {
|
| 535 |
let alive = true;
|
| 536 |
setMeta(null); setErr(null); setSource(null); setDims(null); setPages(null);
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
const job = pending.current;
|
| 602 |
-
if (!job) return true;
|
| 603 |
pending.current = null;
|
| 604 |
setStatus('saving'); setSaveErr(null);
|
|
|
|
| 605 |
try {
|
| 606 |
-
const after = await api.writeFile(sessionId, path, job.text, force ?
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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>(
|
| 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>(
|
| 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 |
)}
|
|
@@ -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)]),
|
|
@@ -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 |
+
};
|
|
@@ -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); }
|