Fix crash and session saving.

This commit is contained in:
Greyson Parrelli committed 2026-09-24 12:43:41 -04:00
1 parent 2a0e03cc41
commit 0e8bc70151
9 files changed
+353 -25

No files matched your search

+17 -1
View File
@@ -208,6 +208,10 @@ export default function App() {
// a repeat request for the same file a distinct one, so re-clicking a comment
// after re-folding its file opens it again.
const [reveal, setReveal] = useState<{ file: string; seq: number } | null>(null);
// The file the diff last asked the rail to show, sequenced like `reveal` so
// locating the same file twice still counts.
const [locate, setLocate] = useState<{ file: string; seq: number } | null>(null);
const locateSeq = useRef(0);
const revealSeq = useRef(0);
const [theme, setTheme] = useState<Theme>(initialTheme);
const [sidebarWidth, setSidebarWidth] = useState(() =>
@@ -620,6 +624,11 @@ export default function App() {
waitForComment(c.id, () => scrollToFile(c.file));
}, []);
// Stable, since every file header holds it and FileView is memoized.
const locateFile = useCallback((file: string) => {
setLocate({ file, seq: ++locateSeq.current });
}, []);
const draftCount = useMemo(
() => comments.filter((c) => c.status === 'draft').length,
[comments],
@@ -895,7 +904,13 @@ export default function App() {
/>
)}
{payload && (
<FileList files={payload.files} comments={comments} onSelect={scrollToFile} />
<FileList
files={payload.files}
comments={comments}
viewed={viewed}
locate={locate}
onSelect={scrollToFile}
/>
)}
<div className="sidebar-foot">
{openCount > 0 && (
@@ -947,6 +962,7 @@ export default function App() {
snapshots={snapshots}
reveal={reveal}
onSetViewed={setFileViewed}
onLocate={locateFile}
onStartDraft={setDraft}
onCancelDraft={cancelDraft}
onSubmitDraft={submitDraft}
+14
View File
@@ -64,6 +64,8 @@ interface Props {
// `source` is the file's contents at the moment it was marked, kept so a later
// visit can be diffed against it. Absent when they can't be reconstructed.
onSetViewed: (file: string, viewed: boolean, source?: string) => void;
// Reveals the file in the left rail's file list.
onLocate: (file: string) => void;
onStartDraft: (d: DraftTarget) => void;
onCancelDraft: () => void;
onSubmitDraft: (body: string) => Promise<void>;
@@ -432,6 +434,7 @@ export function DiffView({
snapshots,
reveal,
onSetViewed,
onLocate,
onStartDraft,
onCancelDraft,
onSubmitDraft,
@@ -480,6 +483,7 @@ export function DiffView({
cardRef={cardRef(path)}
bodyRef={bodyRef(path)}
onSetViewed={onSetViewed}
onLocate={onLocate}
onStartDraft={onStartDraft}
onCancelDraft={onCancelDraft}
onSubmitDraft={onSubmitDraft}
@@ -537,6 +541,7 @@ const FileView = memo(function FileView({
cardRef,
bodyRef,
onSetViewed,
onLocate,
onStartDraft,
onCancelDraft,
onSubmitDraft,
@@ -561,6 +566,7 @@ const FileView = memo(function FileView({
cardRef: (el: HTMLElement | null) => void;
bodyRef: (el: HTMLElement | null) => void;
onSetViewed: Props['onSetViewed'];
onLocate: Props['onLocate'];
onStartDraft: (d: DraftTarget) => void;
onCancelDraft: () => void;
onSubmitDraft: Props['onSubmitDraft'];
@@ -749,6 +755,14 @@ const FileView = memo(function FileView({
>
<Icon name="copy" />
</button>
<button
className="icon-btn"
onClick={() => onLocate(path)}
title="Show in file list"
aria-label="Show in file list"
>
<Icon name="focus-center" />
</button>
{file.type !== 'modify' && (
<span className={`file-status file-status-${file.type}`}>
{statusLabel(file.type)}
+73 -6
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Comment, DiffFile } from '../types';
import {
@@ -8,12 +8,18 @@ import {
pathOf,
type DirNode,
type FileNode,
type TreeNode,
} from '../lib/filetree';
import { Icon } from './Icon';
interface Props {
files: DiffFile[];
comments: Comment[];
// Files marked viewed; their rows gray out, as do folders with nothing left.
viewed: ReadonlySet<string>;
// A file the diff asked to be shown here, sequenced so asking twice for the
// same file still counts.
locate: { file: string; seq: number } | null;
onSelect: (path: string) => void;
}
@@ -28,9 +34,12 @@ function initialMode(): Mode {
// FileList is the left rail: every changed file with its stats and open-comment
// count, either as a GitHub-style collapsible folder tree or a flat list.
// Clicking a file scrolls to it.
export function FileList({ files, comments, onSelect }: Props) {
export function FileList({ files, comments, viewed, locate, onSelect }: Props) {
const [mode, setMode] = useState<Mode>(initialMode);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const navRef = useRef<HTMLElement>(null);
// The located file waiting for its row to be on screen to scroll to.
const [pending, setPending] = useState<string | null>(null);
const openByFile = useMemo(() => {
const m = new Map<string, number>();
@@ -43,6 +52,7 @@ export function FileList({ files, comments, onSelect }: Props) {
const tree = useMemo(() => buildTree(files, openByFile), [files, openByFile]);
const rows = useMemo(() => flatten(tree, collapsed), [tree, collapsed]);
const viewedDirs = useMemo(() => fullyViewed(tree, viewed), [tree, viewed]);
const allCollapsed = useMemo(() => {
const dirs = dirPaths(tree);
return dirs.length > 0 && dirs.every((p) => collapsed.has(p));
@@ -63,8 +73,40 @@ export function FileList({ files, comments, onSelect }: Props) {
const toggleAll = () =>
setCollapsed(allCollapsed ? new Set() : new Set(dirPaths(tree)));
// Locating a file opens every folder above it. A folder's collapse key is its
// own path, so those are exactly the prefixes of the file's path — including
// the ones a chain-merged row swallowed, which were never keys to begin with.
useEffect(() => {
if (!locate) return;
const parts = locate.file.split('/');
const ancestors = parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
setCollapsed((prev) => {
if (!ancestors.some((a) => prev.has(a))) return prev;
const next = new Set(prev);
for (const a of ancestors) next.delete(a);
return next;
});
setPending(locate.file);
}, [locate]);
// Then, once the render that opened those folders has the row in the DOM,
// bring it into view and pulse it so the eye finds it.
useEffect(() => {
if (pending == null) return;
const el = navRef.current?.querySelector<HTMLElement>(
`[data-path="${CSS.escape(pending)}"]`,
);
if (!el) return;
setPending(null);
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('is-flashed');
void el.offsetWidth; // restart the flash when the same row is located again
el.classList.add('is-flashed');
window.setTimeout(() => el.classList.remove('is-flashed'), 1800);
}, [pending, rows, mode]);
return (
<nav className="filelist">
<nav className="filelist" ref={navRef}>
<div className="filelist-head">
<span>
{files.length} file{files.length === 1 ? '' : 's'} changed
@@ -105,6 +147,7 @@ export function FileList({ files, comments, onSelect }: Props) {
}}
depth={0}
showDir
viewed={viewed.has(pathOf(f))}
onSelect={onSelect}
/>
))
@@ -115,6 +158,7 @@ export function FileList({ files, comments, onSelect }: Props) {
node={node}
depth={depth}
collapsed={collapsed.has(node.path)}
viewed={viewedDirs.has(node.path)}
onToggle={() => toggleDir(node.path)}
/>
) : (
@@ -122,6 +166,7 @@ export function FileList({ files, comments, onSelect }: Props) {
key={node.path}
node={node}
depth={depth}
viewed={viewed.has(node.path)}
onSelect={onSelect}
/>
),
@@ -131,6 +176,23 @@ export function FileList({ files, comments, onSelect }: Props) {
);
}
// fullyViewed collects the folders whose every file is marked viewed, so a
// folder you've finished steps back along with its files — and stays that way
// when it's folded away and its files can't say so themselves.
function fullyViewed(nodes: TreeNode[], viewed: ReadonlySet<string>): Set<string> {
const out = new Set<string>();
const walk = (n: TreeNode): boolean => {
if (n.kind === 'file') return viewed.has(n.path);
// Every child is walked, not just up to the first unviewed one: a folder
// deeper down can be done even when its parent isn't.
const done = n.children.map(walk).every(Boolean);
if (done) out.add(n.path);
return done;
};
nodes.forEach(walk);
return out;
}
// indent mirrors the tree depth; the chevron column keeps files aligned with
// the folder name above them.
function indent(depth: number) {
@@ -141,17 +203,19 @@ function DirRow({
node,
depth,
collapsed,
viewed,
onToggle,
}: {
node: DirNode;
depth: number;
collapsed: boolean;
viewed: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
className="filelist-dir-row"
className={`filelist-dir-row${viewed ? ' is-viewed' : ''}`}
style={indent(depth)}
onClick={onToggle}
title={node.path}
@@ -189,11 +253,13 @@ function FileRow({
node,
depth,
showDir = false,
viewed,
onSelect,
}: {
node: FileNode;
depth: number;
showDir?: boolean;
viewed: boolean;
onSelect: (path: string) => void;
}) {
const name = showDir ? node.path.split('/').pop() : node.name;
@@ -202,10 +268,11 @@ function FileRow({
return (
<li>
<button
className="filelist-file-row"
className={`filelist-file-row${viewed ? ' is-viewed' : ''}`}
style={indent(depth)}
onClick={() => onSelect(node.path)}
title={node.path}
title={viewed ? `${node.path} (viewed)` : node.path}
data-path={node.path}
>
<span className="filelist-chevron" />
<span className={`filelist-icon is-${node.file.status}`}>
+2
View File
@@ -72,6 +72,8 @@ const PATHS = {
'M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5A1.75 1.75 0 0 1 3.75 2Zm6.854-1h3.396a.25.25 0 0 1 .25.25v3.396a.25.25 0 0 1-.427.177L12.5 3.561 8.53 7.53a.75.75 0 0 1-1.06-1.06l3.969-3.97-1.262-1.323a.25.25 0 0 1 .177-.427Z',
pencil:
'M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z',
'focus-center':
'M2.75 2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5ZM10 1.75a.75.75 0 0 1 .75-.75h2.5c.966 0 1.75.784 1.75 1.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1-.75-.75ZM1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM8 10a2 2 0 1 0 .001-3.999A2 2 0 0 0 8 10Z',
clock:
'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z',
} as const;
+29
View File
@@ -972,6 +972,35 @@ body.is-resizing {
.filelist-stats .del {
color: var(--del-fg);
}
/* Viewed files step back here as they do in the diff, and so does a folder with
nothing left to read in it; hovering brings a row back to full strength. */
.filelist-dir-row.is-viewed,
.filelist-file-row.is-viewed {
opacity: 0.55;
}
.filelist-dir-row.is-viewed:hover,
.filelist-file-row.is-viewed:hover {
opacity: 1;
}
.filelist-file-row.is-viewed .filelist-name,
.filelist-dir-row.is-viewed .filelist-folder {
color: var(--muted);
}
/* Pulse when the diff asks the rail to show a file. */
.filelist-file-row.is-flashed {
animation: filelist-flash 1.8s ease-out;
}
@keyframes filelist-flash {
0%,
35% {
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-emphasis);
opacity: 1;
}
100% {
box-shadow: inset 0 0 0 1px transparent;
}
}
/* GitHub marks a file's unresolved conversations with a comment glyph + count. */
.filelist-badge {
display: inline-flex;