@archastro/astroshot-review 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/astroshot-review.mjs +17 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +240 -0
- package/dist/data/friction.d.ts +22 -0
- package/dist/data/friction.js +278 -0
- package/dist/data/hash-cache.d.ts +17 -0
- package/dist/data/hash-cache.js +51 -0
- package/dist/data/index-cache.d.ts +22 -0
- package/dist/data/index-cache.js +64 -0
- package/dist/data/manifest.d.ts +41 -0
- package/dist/data/manifest.js +105 -0
- package/dist/data/model.d.ts +96 -0
- package/dist/data/model.js +1 -0
- package/dist/data/paths.d.ts +27 -0
- package/dist/data/paths.js +98 -0
- package/dist/data/review-store.d.ts +67 -0
- package/dist/data/review-store.js +237 -0
- package/dist/data/scan.d.ts +32 -0
- package/dist/data/scan.js +227 -0
- package/dist/data/store.d.ts +92 -0
- package/dist/data/store.js +408 -0
- package/dist/data/watcher.d.ts +37 -0
- package/dist/data/watcher.js +126 -0
- package/dist/images/halfblocks.d.ts +10 -0
- package/dist/images/halfblocks.js +21 -0
- package/dist/images/png.d.ts +14 -0
- package/dist/images/png.js +45 -0
- package/dist/images/scale.d.ts +31 -0
- package/dist/images/scale.js +102 -0
- package/dist/images/service.d.ts +54 -0
- package/dist/images/service.js +163 -0
- package/dist/images/worker.d.ts +21 -0
- package/dist/images/worker.js +30 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/terminal/graphics-stdout.d.ts +9 -0
- package/dist/terminal/graphics-stdout.js +36 -0
- package/dist/terminal/herdr.d.ts +61 -0
- package/dist/terminal/herdr.js +327 -0
- package/dist/terminal/image-layer.d.ts +122 -0
- package/dist/terminal/image-layer.js +471 -0
- package/dist/terminal/kitty.d.ts +74 -0
- package/dist/terminal/kitty.js +112 -0
- package/dist/terminal/probe.d.ts +49 -0
- package/dist/terminal/probe.js +206 -0
- package/dist/ui/app.d.ts +6 -0
- package/dist/ui/app.js +695 -0
- package/dist/ui/chrome.d.ts +53 -0
- package/dist/ui/chrome.js +69 -0
- package/dist/ui/context.d.ts +16 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/detail.d.ts +33 -0
- package/dist/ui/detail.js +39 -0
- package/dist/ui/friction.d.ts +42 -0
- package/dist/ui/friction.js +84 -0
- package/dist/ui/help.d.ts +4 -0
- package/dist/ui/help.js +61 -0
- package/dist/ui/hooks.d.ts +9 -0
- package/dist/ui/hooks.js +32 -0
- package/dist/ui/movie-player.d.ts +29 -0
- package/dist/ui/movie-player.js +119 -0
- package/dist/ui/picture.d.ts +19 -0
- package/dist/ui/picture.js +100 -0
- package/dist/ui/selectors.d.ts +26 -0
- package/dist/ui/selectors.js +69 -0
- package/dist/ui/settings.d.ts +5 -0
- package/dist/ui/settings.js +17 -0
- package/dist/ui/stream.d.ts +38 -0
- package/dist/ui/stream.js +118 -0
- package/dist/ui/system.d.ts +4 -0
- package/dist/ui/system.js +34 -0
- package/dist/ui/takeover.d.ts +26 -0
- package/dist/ui/takeover.js +29 -0
- package/dist/ui/text-input.d.ts +8 -0
- package/dist/ui/text-input.js +74 -0
- package/dist/ui/theme.d.ts +21 -0
- package/dist/ui/theme.js +63 -0
- package/dist/video/ffmpeg.d.ts +54 -0
- package/dist/video/ffmpeg.js +206 -0
- package/package.json +71 -0
package/dist/ui/app.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The tray shell: header, tabs, the active pane, and the keymap. Navigation
|
|
4
|
+
* follows the macOS app — Detail pages the whole stream newest-first while
|
|
5
|
+
* full-screen review pages run siblings oldest-first.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { HintBar, Toast } from "./chrome.js";
|
|
12
|
+
import { useServices } from "./context.js";
|
|
13
|
+
import { DetailPane } from "./detail.js";
|
|
14
|
+
import { FRICTION_ROW_HEIGHT, FrictionFilterBar, FrictionList, FrictionLogDetail, FrictionStepDetail, FrictionStepTakeover, frictionScroll, } from "./friction.js";
|
|
15
|
+
import { HelpOverlay } from "./help.js";
|
|
16
|
+
import { useClock, useStoreState, useTerminalSize } from "./hooks.js";
|
|
17
|
+
import { contiguousGroups, filterFrictionLogs, filterShots, frictionState, latestRun, reviewSiblings, reviewStateOf, streamCounts, } from "./selectors.js";
|
|
18
|
+
import { SettingsPane } from "./settings.js";
|
|
19
|
+
import { FilterBar, StreamList, flattenStream, nextNavigable, scrollWindow } from "./stream.js";
|
|
20
|
+
import { copyImageToClipboard, openWithDefaultApp, revealInFileManager } from "./system.js";
|
|
21
|
+
import { ReviewTakeover } from "./takeover.js";
|
|
22
|
+
import { theme, truncate } from "./theme.js";
|
|
23
|
+
const SPLIT_BREAKPOINT = 120;
|
|
24
|
+
const SEEK_STEP_MS = 5000;
|
|
25
|
+
function initialPlayback() {
|
|
26
|
+
return { playing: false, positionMs: 0, durationMs: null, seekToken: 0, error: null, ended: false };
|
|
27
|
+
}
|
|
28
|
+
const initialState = {
|
|
29
|
+
tab: "shots",
|
|
30
|
+
pane: "stream",
|
|
31
|
+
streamFilter: "unseen",
|
|
32
|
+
moviesOnly: false,
|
|
33
|
+
collapsed: new Set(),
|
|
34
|
+
cursor: 0,
|
|
35
|
+
scrollTop: 0,
|
|
36
|
+
selectedShot: null,
|
|
37
|
+
frictionFilter: "unseen",
|
|
38
|
+
frictionCursor: 0,
|
|
39
|
+
frictionScrollTop: 0,
|
|
40
|
+
selectedLog: null,
|
|
41
|
+
selectedRun: null,
|
|
42
|
+
stepCursor: 0,
|
|
43
|
+
stepIndex: 0,
|
|
44
|
+
imageIndex: 0,
|
|
45
|
+
promptOpen: false,
|
|
46
|
+
prompt: null,
|
|
47
|
+
takeover: null,
|
|
48
|
+
composer: false,
|
|
49
|
+
help: false,
|
|
50
|
+
toast: null,
|
|
51
|
+
busy: false,
|
|
52
|
+
bulkBusy: false,
|
|
53
|
+
error: null,
|
|
54
|
+
inlinePlayer: false,
|
|
55
|
+
playback: initialPlayback(),
|
|
56
|
+
zoom: 1,
|
|
57
|
+
panX: 0.5,
|
|
58
|
+
panY: 0.5,
|
|
59
|
+
};
|
|
60
|
+
export function App({ onQuit }) {
|
|
61
|
+
const { store, layer, capabilities } = useServices();
|
|
62
|
+
const { exit } = useApp();
|
|
63
|
+
const storeState = useStoreState();
|
|
64
|
+
const { columns, rows } = useTerminalSize();
|
|
65
|
+
const now = useClock(30_000);
|
|
66
|
+
const [ui, setUi] = useState(initialState);
|
|
67
|
+
const uiRef = useRef(ui);
|
|
68
|
+
uiRef.current = ui;
|
|
69
|
+
const patch = useCallback((update) => {
|
|
70
|
+
setUi((previous) => ({ ...previous, ...(typeof update === "function" ? update(previous) : update) }));
|
|
71
|
+
}, []);
|
|
72
|
+
const toast = useCallback((message, durationMs = 1600) => patch({ toast: { message, at: Date.now(), durationMs } }), [patch]);
|
|
73
|
+
// Toasts clear themselves like the app's 1.6 s pill; arrivals linger like the overlay.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!ui.toast)
|
|
76
|
+
return;
|
|
77
|
+
const timer = setTimeout(() => patch((previous) => (previous.toast === ui.toast ? { toast: null } : {})), ui.toast.durationMs);
|
|
78
|
+
return () => clearTimeout(timer);
|
|
79
|
+
}, [ui.toast, patch]);
|
|
80
|
+
// Overlay stand-in: announce arrivals while the tray is open.
|
|
81
|
+
const lastEventRef = useRef(0);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const event = storeState.lastEvent;
|
|
84
|
+
if (!event || event.at === lastEventRef.current)
|
|
85
|
+
return;
|
|
86
|
+
lastEventRef.current = event.at;
|
|
87
|
+
if (event.kind === "new-shot")
|
|
88
|
+
toast(`New · ${event.shot.worktreeShort} · ${event.shot.feature} · ${event.shot.title}`, 5500);
|
|
89
|
+
}, [storeState.lastEvent, toast]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
layer.invalidate();
|
|
92
|
+
}, [columns, rows, layer]);
|
|
93
|
+
// ---- Derived stream data -------------------------------------------------
|
|
94
|
+
const shots = storeState.shots;
|
|
95
|
+
const filtered = useMemo(() => filterShots(shots, ui.streamFilter, ui.moviesOnly), [shots, ui.streamFilter, ui.moviesOnly]);
|
|
96
|
+
const groups = useMemo(() => contiguousGroups(filtered), [filtered]);
|
|
97
|
+
const items = useMemo(() => flattenStream(groups, ui.collapsed), [groups, ui.collapsed]);
|
|
98
|
+
const counts = useMemo(() => streamCounts(shots, ui.moviesOnly), [shots, ui.moviesOnly]);
|
|
99
|
+
const cursor = items.length === 0 ? 0 : nextNavigable(items, ui.collapsed, Math.min(ui.cursor, items.length - 1), 1);
|
|
100
|
+
const cursorItem = items[cursor] ?? null;
|
|
101
|
+
const cursorShot = cursorItem?.kind === "shot" ? cursorItem.shot : null;
|
|
102
|
+
const selectedShot = useMemo(() => (ui.selectedShot ? shots.find((shot) => shot.path === ui.selectedShot) ?? null : null), [shots, ui.selectedShot]);
|
|
103
|
+
const activeShot = ui.pane === "detail" || ui.takeover?.kind === "shot" ? (selectedShot ?? cursorShot) : (cursorShot ?? selectedShot);
|
|
104
|
+
const frictionLogs = storeState.frictionLogs;
|
|
105
|
+
const frictionFiltered = useMemo(() => filterFrictionLogs(frictionLogs, ui.frictionFilter), [frictionLogs, ui.frictionFilter]);
|
|
106
|
+
const frictionCounts = useMemo(() => ({
|
|
107
|
+
pending: frictionLogs.filter((log) => frictionState(log) !== "seen").length,
|
|
108
|
+
seen: frictionLogs.filter((log) => frictionState(log) === "seen").length,
|
|
109
|
+
}), [frictionLogs]);
|
|
110
|
+
const frictionCursor = Math.min(ui.frictionCursor, Math.max(0, frictionFiltered.length - 1));
|
|
111
|
+
const selectedLog = useMemo(() => (ui.selectedLog ? frictionLogs.find((log) => log.id === ui.selectedLog) ?? null : null) ?? frictionFiltered[frictionCursor] ?? null, [frictionLogs, frictionFiltered, frictionCursor, ui.selectedLog]);
|
|
112
|
+
const selectedRun = useMemo(() => {
|
|
113
|
+
if (!selectedLog)
|
|
114
|
+
return null;
|
|
115
|
+
return selectedLog.runs.find((run) => run.runId === ui.selectedRun) ?? latestRun(selectedLog);
|
|
116
|
+
}, [selectedLog, ui.selectedRun]);
|
|
117
|
+
// ---- Layout ----------------------------------------------------------------
|
|
118
|
+
const split = columns >= SPLIT_BREAKPOINT;
|
|
119
|
+
const bodyHeight = Math.max(6, rows - 4);
|
|
120
|
+
const listWidth = split ? Math.max(52, Math.floor(columns * 0.42)) : columns;
|
|
121
|
+
const paneWidth = split ? columns - listWidth - 1 : columns;
|
|
122
|
+
const listHeight = bodyHeight;
|
|
123
|
+
const scrollTop = useMemo(() => scrollWindow(items, cursor, ui.scrollTop, listHeight), [items, cursor, ui.scrollTop, listHeight]);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (scrollTop !== ui.scrollTop)
|
|
126
|
+
patch({ scrollTop });
|
|
127
|
+
}, [scrollTop, ui.scrollTop, patch]);
|
|
128
|
+
const frictionTop = frictionScroll(frictionFiltered.length, frictionCursor, ui.frictionScrollTop, listHeight);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (frictionTop !== ui.frictionScrollTop)
|
|
131
|
+
patch({ frictionScrollTop: frictionTop });
|
|
132
|
+
}, [frictionTop, ui.frictionScrollTop, patch]);
|
|
133
|
+
// Reset per-shot transient state when the active shot changes.
|
|
134
|
+
const activePathRef = useRef(null);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const current = activeShot?.path ?? null;
|
|
137
|
+
if (current !== activePathRef.current) {
|
|
138
|
+
activePathRef.current = current;
|
|
139
|
+
patch({ inlinePlayer: false, playback: initialPlayback(), composer: false, error: null, zoom: 1, panX: 0.5, panY: 0.5 });
|
|
140
|
+
}
|
|
141
|
+
}, [activeShot?.path, patch]);
|
|
142
|
+
// ---- Actions ----------------------------------------------------------------
|
|
143
|
+
const quit = useCallback(() => {
|
|
144
|
+
onQuit?.();
|
|
145
|
+
exit();
|
|
146
|
+
}, [exit, onQuit]);
|
|
147
|
+
const stepDetail = useCallback((delta) => {
|
|
148
|
+
const current = uiRef.current;
|
|
149
|
+
const currentPath = current.selectedShot ?? cursorShot?.path ?? null;
|
|
150
|
+
const index = shots.findIndex((shot) => shot.path === currentPath);
|
|
151
|
+
if (index === -1)
|
|
152
|
+
return;
|
|
153
|
+
const next = index + delta;
|
|
154
|
+
if (next < 0 || next >= shots.length)
|
|
155
|
+
return;
|
|
156
|
+
patch({ selectedShot: shots[next].path });
|
|
157
|
+
}, [shots, cursorShot, patch]);
|
|
158
|
+
const stepTakeover = useCallback((delta) => {
|
|
159
|
+
if (!activeShot)
|
|
160
|
+
return;
|
|
161
|
+
const siblings = reviewSiblings(shots, activeShot);
|
|
162
|
+
const index = siblings.findIndex((shot) => shot.path === activeShot.path);
|
|
163
|
+
const next = index + delta;
|
|
164
|
+
if (next < 0 || next >= siblings.length)
|
|
165
|
+
return;
|
|
166
|
+
patch({ selectedShot: siblings[next].path });
|
|
167
|
+
}, [activeShot, shots, patch]);
|
|
168
|
+
const markSeen = useCallback(async (shot, comment) => {
|
|
169
|
+
if (reviewStateOf(shot) === "seen" && !comment?.trim()) {
|
|
170
|
+
toast("Already seen");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
patch({ busy: true, error: null });
|
|
174
|
+
try {
|
|
175
|
+
await store.markShotSeen(shot, comment);
|
|
176
|
+
toast("Seen");
|
|
177
|
+
patch((previous) => {
|
|
178
|
+
// Only leave the surface the user was on for THIS shot; if they paged
|
|
179
|
+
// on while the write was queued, stay put.
|
|
180
|
+
const stillHere = previous.selectedShot === shot.path;
|
|
181
|
+
return {
|
|
182
|
+
busy: false,
|
|
183
|
+
composer: stillHere ? false : previous.composer,
|
|
184
|
+
takeover: stillHere && previous.takeover?.kind === "shot" ? null : previous.takeover,
|
|
185
|
+
pane: stillHere && previous.pane === "detail" ? "stream" : previous.pane,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
patch({ busy: false, error: error instanceof Error ? error.message : String(error) });
|
|
191
|
+
}
|
|
192
|
+
}, [store, patch, toast]);
|
|
193
|
+
const sendFeedback = useCallback(async (shot, body) => {
|
|
194
|
+
if (!body.trim()) {
|
|
195
|
+
patch({ composer: false });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
patch({ busy: true, error: null });
|
|
199
|
+
try {
|
|
200
|
+
await store.addShotComment(shot, body);
|
|
201
|
+
toast("Comment added");
|
|
202
|
+
patch((previous) => ({ busy: false, composer: previous.selectedShot === shot.path ? false : previous.composer }));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
patch({ busy: false, error: error instanceof Error ? error.message : String(error) });
|
|
206
|
+
}
|
|
207
|
+
}, [store, patch, toast]);
|
|
208
|
+
const seenAll = useCallback(async (pool) => {
|
|
209
|
+
const targets = pool.filter((shot) => reviewStateOf(shot) !== "seen");
|
|
210
|
+
if (targets.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
patch({ bulkBusy: true });
|
|
213
|
+
const result = await store.markManySeen(targets);
|
|
214
|
+
patch({ bulkBusy: false });
|
|
215
|
+
if (result.failed === 0)
|
|
216
|
+
toast(result.ok === 1 ? "Marked 1 frame seen" : `Marked ${result.ok} frames seen`);
|
|
217
|
+
else if (result.ok === 0)
|
|
218
|
+
toast("Couldn’t mark frames as seen");
|
|
219
|
+
else
|
|
220
|
+
toast(`Marked ${result.ok} seen; ${result.failed} failed`);
|
|
221
|
+
}, [store, patch, toast]);
|
|
222
|
+
const markLogSeen = useCallback(async (log) => {
|
|
223
|
+
const run = latestRun(log);
|
|
224
|
+
if (!run)
|
|
225
|
+
return;
|
|
226
|
+
if (frictionState(log) === "seen") {
|
|
227
|
+
toast("Already seen");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
await store.markFrictionRunSeen(log, run);
|
|
232
|
+
toast("Marked 1 log seen");
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
toast(error instanceof Error ? error.message : "Couldn’t mark logs as seen");
|
|
236
|
+
}
|
|
237
|
+
}, [store, toast]);
|
|
238
|
+
const seenAllLogs = useCallback(async (logs) => {
|
|
239
|
+
const targets = logs.filter((log) => frictionState(log) !== "seen" && latestRun(log));
|
|
240
|
+
if (targets.length === 0)
|
|
241
|
+
return;
|
|
242
|
+
let ok = 0;
|
|
243
|
+
for (const log of targets) {
|
|
244
|
+
try {
|
|
245
|
+
await store.markFrictionRunSeen(log, latestRun(log));
|
|
246
|
+
ok += 1;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// counted below
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
toast(ok === 0 ? "Couldn’t mark logs as seen" : ok === 1 ? "Marked 1 log seen" : `Marked ${ok} logs seen`);
|
|
253
|
+
}, [store, toast]);
|
|
254
|
+
const desktop = useCallback(async (task, success, failure) => {
|
|
255
|
+
try {
|
|
256
|
+
await task;
|
|
257
|
+
if (success)
|
|
258
|
+
toast(success);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
toast(failure);
|
|
262
|
+
}
|
|
263
|
+
}, [toast]);
|
|
264
|
+
const togglePrompt = useCallback(() => {
|
|
265
|
+
const log = selectedLog;
|
|
266
|
+
if (!log?.promptPath)
|
|
267
|
+
return;
|
|
268
|
+
if (uiRef.current.promptOpen) {
|
|
269
|
+
patch({ promptOpen: false });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let prompt = "";
|
|
273
|
+
try {
|
|
274
|
+
prompt = fs.readFileSync(log.promptPath, "utf8");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
prompt = "(prompt.md could not be read)";
|
|
278
|
+
}
|
|
279
|
+
patch({ promptOpen: true, prompt });
|
|
280
|
+
}, [selectedLog, patch]);
|
|
281
|
+
const updatePlayback = useCallback((update) => patch((previous) => ({ playback: { ...previous.playback, ...update } })), [patch]);
|
|
282
|
+
const seekBy = useCallback((deltaMs) => {
|
|
283
|
+
patch((previous) => {
|
|
284
|
+
const duration = previous.playback.durationMs ?? activeShot?.durationMs ?? null;
|
|
285
|
+
const target = Math.max(0, duration ? Math.min(duration, previous.playback.positionMs + deltaMs) : previous.playback.positionMs + deltaMs);
|
|
286
|
+
return { playback: { ...previous.playback, positionMs: target, seekToken: previous.playback.seekToken + 1, ended: false } };
|
|
287
|
+
});
|
|
288
|
+
}, [patch, activeShot]);
|
|
289
|
+
const seekChapter = useCallback((direction) => {
|
|
290
|
+
if (!activeShot)
|
|
291
|
+
return;
|
|
292
|
+
const marks = activeShot.chapters.map((chapter) => chapter.tMs).filter((value) => typeof value === "number").sort((a, b) => a - b);
|
|
293
|
+
if (marks.length === 0)
|
|
294
|
+
return;
|
|
295
|
+
patch((previous) => {
|
|
296
|
+
const position = previous.playback.positionMs;
|
|
297
|
+
const target = direction > 0 ? marks.find((mark) => mark > position + 200) : [...marks].reverse().find((mark) => mark < position - 200);
|
|
298
|
+
const resolved = target ?? (direction > 0 ? marks.at(-1) : 0);
|
|
299
|
+
return { playback: { ...previous.playback, positionMs: resolved, seekToken: previous.playback.seekToken + 1, ended: false } };
|
|
300
|
+
});
|
|
301
|
+
}, [activeShot, patch]);
|
|
302
|
+
const canInlinePlay = capabilities.graphics === "kitty" || capabilities.graphics === "herdr";
|
|
303
|
+
const zoomBy = useCallback((delta) => patch((previous) => {
|
|
304
|
+
const zoom = Math.max(1, Math.min(6, Math.round((previous.zoom + delta) * 100) / 100));
|
|
305
|
+
// Snapping back to 1 recenters so the next zoom-in starts from the middle.
|
|
306
|
+
return zoom === 1 ? { zoom, panX: 0.5, panY: 0.5 } : { zoom };
|
|
307
|
+
}), [patch]);
|
|
308
|
+
const panBy = useCallback((dx, dy) => patch((previous) => {
|
|
309
|
+
if (previous.zoom <= 1)
|
|
310
|
+
return {};
|
|
311
|
+
const step = 0.18 / previous.zoom;
|
|
312
|
+
return {
|
|
313
|
+
panX: Math.max(0, Math.min(1, previous.panX + dx * step)),
|
|
314
|
+
panY: Math.max(0, Math.min(1, previous.panY + dy * step)),
|
|
315
|
+
};
|
|
316
|
+
}), [patch]);
|
|
317
|
+
const togglePlay = useCallback(() => {
|
|
318
|
+
if (!activeShot?.videoPath) {
|
|
319
|
+
toast(activeShot?.isMovie ? "Video missing on disk" : "Not a movie");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!canInlinePlay) {
|
|
323
|
+
toast("In-tray playback needs Kitty graphics — press O to open the movie");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
patch((previous) => ({
|
|
327
|
+
inlinePlayer: true,
|
|
328
|
+
playback: previous.playback.ended
|
|
329
|
+
? { ...previous.playback, playing: true, positionMs: 0, seekToken: previous.playback.seekToken + 1, ended: false, error: null }
|
|
330
|
+
: { ...previous.playback, playing: !previous.playback.playing, error: null },
|
|
331
|
+
}));
|
|
332
|
+
}, [activeShot, canInlinePlay, patch, toast]);
|
|
333
|
+
// ---- Keymap -------------------------------------------------------------------
|
|
334
|
+
useInput((input, key) => {
|
|
335
|
+
const state = uiRef.current;
|
|
336
|
+
if (state.help) {
|
|
337
|
+
if (input === "?" || key.escape || input === "q")
|
|
338
|
+
patch({ help: false });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (input === "?") {
|
|
342
|
+
patch({ help: true });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (key.ctrl && input === "c") {
|
|
346
|
+
quit();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// Full-screen takeovers.
|
|
350
|
+
if (state.takeover?.kind === "shot" && activeShot) {
|
|
351
|
+
if (key.escape || input === "q") {
|
|
352
|
+
patch({ takeover: null, composer: false });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// When zoomed in, the arrows pan the image; otherwise they page.
|
|
356
|
+
const zoomed = state.zoom > 1;
|
|
357
|
+
if (key.leftArrow || input === "h")
|
|
358
|
+
return zoomed ? panBy(-1, 0) : stepTakeover(-1);
|
|
359
|
+
if (key.rightArrow || input === "l")
|
|
360
|
+
return zoomed ? panBy(1, 0) : stepTakeover(1);
|
|
361
|
+
if (key.upArrow || input === "k")
|
|
362
|
+
return zoomed ? panBy(0, -1) : undefined;
|
|
363
|
+
if (key.downArrow || input === "j")
|
|
364
|
+
return zoomed ? panBy(0, 1) : undefined;
|
|
365
|
+
if (input === "c")
|
|
366
|
+
return patch({ composer: true, error: null });
|
|
367
|
+
if (input === "s")
|
|
368
|
+
return void markSeen(activeShot);
|
|
369
|
+
if (input === " ")
|
|
370
|
+
return togglePlay();
|
|
371
|
+
if (input === ",")
|
|
372
|
+
return seekBy(-SEEK_STEP_MS);
|
|
373
|
+
if (input === ".")
|
|
374
|
+
return seekBy(SEEK_STEP_MS);
|
|
375
|
+
if (input === "[")
|
|
376
|
+
return seekChapter(-1);
|
|
377
|
+
if (input === "]")
|
|
378
|
+
return seekChapter(1);
|
|
379
|
+
if (input === "+" || input === "=")
|
|
380
|
+
return zoomBy(0.5);
|
|
381
|
+
if (input === "-" || input === "_")
|
|
382
|
+
return zoomBy(-0.5);
|
|
383
|
+
if (input === "0")
|
|
384
|
+
return patch({ zoom: 1, panX: 0.5, panY: 0.5 });
|
|
385
|
+
if (input === "y")
|
|
386
|
+
return void desktop(copyImageToClipboard(activeShot.path), "Copied image", "Couldn’t copy image");
|
|
387
|
+
if (input === "o")
|
|
388
|
+
return void desktop(revealInFileManager(activeShot.path), "", "Couldn’t reveal file");
|
|
389
|
+
if (input === "O" && activeShot.videoPath)
|
|
390
|
+
return void desktop(openWithDefaultApp(activeShot.videoPath), "", "Couldn’t open movie");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (state.takeover?.kind === "step" && selectedRun) {
|
|
394
|
+
if (key.escape || input === "q")
|
|
395
|
+
return patch({ takeover: null });
|
|
396
|
+
if (key.leftArrow || input === "h")
|
|
397
|
+
return patch({ stepIndex: Math.max(0, state.stepIndex - 1), imageIndex: 0 });
|
|
398
|
+
if (key.rightArrow || input === "l")
|
|
399
|
+
return patch({ stepIndex: Math.min(selectedRun.steps.length - 1, state.stepIndex + 1), imageIndex: 0 });
|
|
400
|
+
if (input === "[")
|
|
401
|
+
return patch({ imageIndex: Math.max(0, state.imageIndex - 1) });
|
|
402
|
+
if (input === "]") {
|
|
403
|
+
const count = selectedRun.steps[state.stepIndex]?.screenshots.length ?? 0;
|
|
404
|
+
return patch({ imageIndex: Math.min(Math.max(0, count - 1), state.imageIndex + 1) });
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Global keys.
|
|
409
|
+
if (input === "q")
|
|
410
|
+
return quit();
|
|
411
|
+
// Detail-pane seek keys outrank the global settings toggle while a movie is loaded.
|
|
412
|
+
if (state.tab === "shots" && state.pane === "detail" && state.inlinePlayer && activeShot?.videoPath) {
|
|
413
|
+
if (input === ",")
|
|
414
|
+
return seekBy(-SEEK_STEP_MS);
|
|
415
|
+
if (input === ".")
|
|
416
|
+
return seekBy(SEEK_STEP_MS);
|
|
417
|
+
}
|
|
418
|
+
if (input === "1")
|
|
419
|
+
return patch({ tab: "shots", pane: "stream", takeover: null });
|
|
420
|
+
if (input === "2")
|
|
421
|
+
return patch({ tab: "frictionLogs", pane: "stream", takeover: null });
|
|
422
|
+
if (key.tab)
|
|
423
|
+
return patch((previous) => ({ tab: previous.tab === "shots" ? "frictionLogs" : "shots", pane: "stream" }));
|
|
424
|
+
if (input === ",")
|
|
425
|
+
return patch((previous) => ({ pane: previous.pane === "settings" ? "stream" : "settings" }));
|
|
426
|
+
if (input === "r") {
|
|
427
|
+
toast("Scanning…");
|
|
428
|
+
void store.rescan({ force: true });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (state.pane === "settings") {
|
|
432
|
+
if (key.escape || input === "h" || key.leftArrow)
|
|
433
|
+
patch({ pane: "stream" });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (state.tab === "shots") {
|
|
437
|
+
if (state.pane === "detail" && activeShot) {
|
|
438
|
+
if (key.escape || (input === "h" && !split))
|
|
439
|
+
return patch({ pane: "stream", composer: false });
|
|
440
|
+
if (key.leftArrow)
|
|
441
|
+
return stepDetail(1);
|
|
442
|
+
if (key.rightArrow)
|
|
443
|
+
return stepDetail(-1);
|
|
444
|
+
if (key.return || input === "f")
|
|
445
|
+
return patch({ takeover: { kind: "shot" } });
|
|
446
|
+
if (input === "c")
|
|
447
|
+
return patch({ composer: true, error: null });
|
|
448
|
+
if (input === "s")
|
|
449
|
+
return void markSeen(activeShot);
|
|
450
|
+
if (input === "p") {
|
|
451
|
+
if (!activeShot.videoPath)
|
|
452
|
+
return toast(activeShot.isMovie ? "Video missing on disk" : "Not a movie");
|
|
453
|
+
if (!canInlinePlay)
|
|
454
|
+
return toast("In-tray playback needs Kitty graphics — press O to open the movie");
|
|
455
|
+
return patch((previous) => ({
|
|
456
|
+
inlinePlayer: !previous.inlinePlayer,
|
|
457
|
+
playback: { ...previous.playback, playing: !previous.inlinePlayer, error: null },
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
if (input === " ")
|
|
461
|
+
return togglePlay();
|
|
462
|
+
if (input === ",")
|
|
463
|
+
return seekBy(-SEEK_STEP_MS);
|
|
464
|
+
if (input === ".")
|
|
465
|
+
return seekBy(SEEK_STEP_MS);
|
|
466
|
+
if (input === "[")
|
|
467
|
+
return seekChapter(-1);
|
|
468
|
+
if (input === "]")
|
|
469
|
+
return seekChapter(1);
|
|
470
|
+
if (input === "+" || input === "=")
|
|
471
|
+
return zoomBy(0.5);
|
|
472
|
+
if (input === "-" || input === "_")
|
|
473
|
+
return zoomBy(-0.5);
|
|
474
|
+
if (input === "0")
|
|
475
|
+
return patch({ zoom: 1, panX: 0.5, panY: 0.5 });
|
|
476
|
+
if (input === "o")
|
|
477
|
+
return void desktop(revealInFileManager(activeShot.path), "", "Couldn’t reveal file");
|
|
478
|
+
if (input === "O" && activeShot.videoPath)
|
|
479
|
+
return void desktop(openWithDefaultApp(activeShot.videoPath), "", "Couldn’t open movie");
|
|
480
|
+
if (input === "y")
|
|
481
|
+
return void desktop(copyImageToClipboard(activeShot.path), "Copied image", "Couldn’t copy image");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Stream list.
|
|
485
|
+
store.markOpened();
|
|
486
|
+
const move = (delta) => {
|
|
487
|
+
const target = Math.max(0, Math.min(items.length - 1, cursor + delta));
|
|
488
|
+
return patch({ cursor: nextNavigable(items, state.collapsed, target, delta >= 0 ? 1 : -1) });
|
|
489
|
+
};
|
|
490
|
+
if (key.downArrow || input === "j")
|
|
491
|
+
return move(1);
|
|
492
|
+
if (key.upArrow || input === "k")
|
|
493
|
+
return move(-1);
|
|
494
|
+
if (key.pageDown)
|
|
495
|
+
return move(Math.max(1, Math.floor(listHeight / 4)));
|
|
496
|
+
if (key.pageUp)
|
|
497
|
+
return move(-Math.max(1, Math.floor(listHeight / 4)));
|
|
498
|
+
if (input === "g" || key.home)
|
|
499
|
+
return patch({ cursor: nextNavigable(items, state.collapsed, 0, 1) });
|
|
500
|
+
if (input === "G" || key.end)
|
|
501
|
+
return patch({ cursor: nextNavigable(items, state.collapsed, Math.max(0, items.length - 1), -1) });
|
|
502
|
+
if (input === "u")
|
|
503
|
+
return patch((previous) => ({ streamFilter: previous.streamFilter === "unseen" ? "history" : "unseen", cursor: 0 }));
|
|
504
|
+
if (input === "m")
|
|
505
|
+
return patch((previous) => ({ moviesOnly: !previous.moviesOnly, cursor: 0 }));
|
|
506
|
+
if (input === "z" && cursorItem) {
|
|
507
|
+
const id = cursorItem.group.id;
|
|
508
|
+
return patch((previous) => {
|
|
509
|
+
const collapsed = new Set(previous.collapsed);
|
|
510
|
+
if (collapsed.has(id))
|
|
511
|
+
collapsed.delete(id);
|
|
512
|
+
else
|
|
513
|
+
collapsed.add(id);
|
|
514
|
+
return { collapsed };
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (cursorItem?.kind === "header") {
|
|
518
|
+
if (key.return || key.leftArrow || key.rightArrow || input === " ") {
|
|
519
|
+
const id = cursorItem.group.id;
|
|
520
|
+
return patch((previous) => {
|
|
521
|
+
const collapsed = new Set(previous.collapsed);
|
|
522
|
+
if (collapsed.has(id))
|
|
523
|
+
collapsed.delete(id);
|
|
524
|
+
else
|
|
525
|
+
collapsed.add(id);
|
|
526
|
+
return { collapsed };
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (input === "S")
|
|
530
|
+
return void seenAll(cursorItem.group.shots);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (input === "S")
|
|
534
|
+
return void seenAll(filtered);
|
|
535
|
+
if (input === "A" && cursorItem)
|
|
536
|
+
return void seenAll(cursorItem.group.shots);
|
|
537
|
+
if (!cursorShot)
|
|
538
|
+
return;
|
|
539
|
+
if (key.return)
|
|
540
|
+
return patch(split ? { selectedShot: cursorShot.path, takeover: { kind: "shot" } } : { selectedShot: cursorShot.path, pane: "detail" });
|
|
541
|
+
if (input === "f" || input === " ")
|
|
542
|
+
return patch({ selectedShot: cursorShot.path, takeover: { kind: "shot" } });
|
|
543
|
+
if (input === "s")
|
|
544
|
+
return void markSeen(cursorShot);
|
|
545
|
+
if (input === "c")
|
|
546
|
+
return patch({ selectedShot: cursorShot.path, composer: true, error: null, ...(split ? {} : { pane: "detail" }) });
|
|
547
|
+
if (input === "p") {
|
|
548
|
+
if (!cursorShot.videoPath)
|
|
549
|
+
return toast(cursorShot.isMovie ? "Video missing on disk" : "Not a movie");
|
|
550
|
+
if (!canInlinePlay) {
|
|
551
|
+
patch({ selectedShot: cursorShot.path, ...(split ? {} : { pane: "detail" }) });
|
|
552
|
+
toast("In-tray playback needs Kitty graphics — press O to open the movie");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
return patch(split ? { selectedShot: cursorShot.path, inlinePlayer: true, playback: { ...initialPlayback(), playing: true } } : { selectedShot: cursorShot.path, pane: "detail", inlinePlayer: true, playback: { ...initialPlayback(), playing: true } });
|
|
556
|
+
}
|
|
557
|
+
if (input === "o")
|
|
558
|
+
return void desktop(revealInFileManager(cursorShot.path), "", "Couldn’t reveal file");
|
|
559
|
+
if (input === "O" && cursorShot.videoPath)
|
|
560
|
+
return void desktop(openWithDefaultApp(cursorShot.videoPath), "", "Couldn’t open movie");
|
|
561
|
+
if (input === "y")
|
|
562
|
+
return void desktop(copyImageToClipboard(cursorShot.path), "Copied image", "Couldn’t copy image");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Friction Logs tab.
|
|
566
|
+
if (state.pane === "frictionStep" && selectedLog && selectedRun) {
|
|
567
|
+
if (key.escape || input === "h")
|
|
568
|
+
return patch({ pane: "frictionLog" });
|
|
569
|
+
if (key.leftArrow)
|
|
570
|
+
return patch({ stepIndex: Math.max(0, state.stepIndex - 1), imageIndex: 0, stepCursor: Math.max(0, state.stepIndex - 1) });
|
|
571
|
+
if (key.rightArrow)
|
|
572
|
+
return patch({ stepIndex: Math.min(selectedRun.steps.length - 1, state.stepIndex + 1), imageIndex: 0, stepCursor: Math.min(selectedRun.steps.length - 1, state.stepIndex + 1) });
|
|
573
|
+
if (input === "[")
|
|
574
|
+
return patch({ imageIndex: Math.max(0, state.imageIndex - 1) });
|
|
575
|
+
if (input === "]") {
|
|
576
|
+
const count = selectedRun.steps[state.stepIndex]?.screenshots.length ?? 0;
|
|
577
|
+
return patch({ imageIndex: Math.min(Math.max(0, count - 1), state.imageIndex + 1) });
|
|
578
|
+
}
|
|
579
|
+
if (key.return || input === "f" || input === " ")
|
|
580
|
+
return patch({ takeover: { kind: "step" } });
|
|
581
|
+
const shotPath = selectedRun.steps[state.stepIndex]?.screenshots[state.imageIndex];
|
|
582
|
+
if (input === "o" && shotPath)
|
|
583
|
+
return void desktop(revealInFileManager(shotPath), "", "Couldn’t reveal file");
|
|
584
|
+
if (input === "y" && shotPath)
|
|
585
|
+
return void desktop(copyImageToClipboard(shotPath), "Copied image", "Couldn’t copy image");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (state.pane === "frictionLog" && selectedLog) {
|
|
589
|
+
if (key.escape || input === "h")
|
|
590
|
+
return patch({ pane: "stream", promptOpen: false });
|
|
591
|
+
const steps = selectedRun?.steps ?? [];
|
|
592
|
+
if (key.downArrow || input === "j")
|
|
593
|
+
return patch({ stepCursor: Math.min(Math.max(0, steps.length - 1), state.stepCursor + 1) });
|
|
594
|
+
if (key.upArrow || input === "k")
|
|
595
|
+
return patch({ stepCursor: Math.max(0, state.stepCursor - 1) });
|
|
596
|
+
if ((key.return || key.rightArrow) && steps.length > 0)
|
|
597
|
+
return patch({ pane: "frictionStep", stepIndex: state.stepCursor, imageIndex: 0 });
|
|
598
|
+
if (input === "p")
|
|
599
|
+
return togglePrompt();
|
|
600
|
+
if (input === "s")
|
|
601
|
+
return void markLogSeen(selectedLog);
|
|
602
|
+
if ((input === "[" || input === "]") && selectedLog.runs.length > 1 && selectedRun) {
|
|
603
|
+
const index = selectedLog.runs.indexOf(selectedRun);
|
|
604
|
+
const next = input === "]" ? Math.min(selectedLog.runs.length - 1, index + 1) : Math.max(0, index - 1);
|
|
605
|
+
return patch({ selectedRun: selectedLog.runs[next].runId, stepCursor: 0 });
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// Friction list.
|
|
610
|
+
if (key.downArrow || input === "j")
|
|
611
|
+
return patch({ frictionCursor: Math.min(Math.max(0, frictionFiltered.length - 1), frictionCursor + 1) });
|
|
612
|
+
if (key.upArrow || input === "k")
|
|
613
|
+
return patch({ frictionCursor: Math.max(0, frictionCursor - 1) });
|
|
614
|
+
if (input === "g" || key.home)
|
|
615
|
+
return patch({ frictionCursor: 0 });
|
|
616
|
+
if (input === "G" || key.end)
|
|
617
|
+
return patch({ frictionCursor: Math.max(0, frictionFiltered.length - 1) });
|
|
618
|
+
if (input === "u")
|
|
619
|
+
return patch((previous) => ({ frictionFilter: previous.frictionFilter === "unseen" ? "history" : "unseen", frictionCursor: 0 }));
|
|
620
|
+
if (input === "S")
|
|
621
|
+
return void seenAllLogs(frictionFiltered);
|
|
622
|
+
const log = frictionFiltered[frictionCursor];
|
|
623
|
+
if (!log)
|
|
624
|
+
return;
|
|
625
|
+
if (key.return || key.rightArrow || input === "l")
|
|
626
|
+
return patch({ selectedLog: log.id, selectedRun: latestRun(log)?.runId ?? null, pane: "frictionLog", stepCursor: 0, promptOpen: false });
|
|
627
|
+
if (input === "s")
|
|
628
|
+
return void markLogSeen(log);
|
|
629
|
+
if (input === "o")
|
|
630
|
+
return void desktop(revealInFileManager(log.directory), "", "Couldn’t reveal folder");
|
|
631
|
+
}, { isActive: !ui.composer });
|
|
632
|
+
// ---- Render ---------------------------------------------------------------------
|
|
633
|
+
const status = storeState.roots.length === 0
|
|
634
|
+
? { text: "Choose watch folders", color: theme.amber }
|
|
635
|
+
: storeState.scanning
|
|
636
|
+
? { text: storeState.phase === "full" ? "Scanning for captures (deep)" : "Scanning for captures", color: theme.amber }
|
|
637
|
+
: shots.length === 0 && frictionLogs.length === 0
|
|
638
|
+
? { text: "Waiting for captures", color: theme.muted }
|
|
639
|
+
: { text: "Live review stream", color: theme.green };
|
|
640
|
+
const hints = ui.takeover?.kind === "shot"
|
|
641
|
+
? [{ key: "esc", label: "close" }, { key: "← →", label: "older · newer" }, { key: "c", label: "feedback" }, { key: "s", label: "seen" }, ...(activeShot?.isMovie ? [{ key: "space", label: "play" }, { key: ", .", label: "seek" }, { key: "[ ]", label: "chapter" }] : []), { key: "y", label: "copy" }, { key: "o", label: "reveal" }]
|
|
642
|
+
: ui.takeover?.kind === "step"
|
|
643
|
+
? [{ key: "esc", label: "close" }, { key: "← →", label: "steps" }, { key: "[ ]", label: "images" }]
|
|
644
|
+
: ui.pane === "settings"
|
|
645
|
+
? [{ key: "esc", label: "back" }, { key: "r", label: "rescan" }, { key: "?", label: "help" }, { key: "q", label: "quit" }]
|
|
646
|
+
: ui.tab === "shots"
|
|
647
|
+
? ui.pane === "detail"
|
|
648
|
+
? [{ key: "esc", label: "back" }, { key: "← →", label: "older · newer" }, { key: "f", label: "full screen" }, { key: "s", label: "seen" }, { key: "c", label: "feedback" }, { key: "p", label: "play" }, { key: "?", label: "help" }]
|
|
649
|
+
: [{ key: "↑↓", label: "move" }, { key: "⏎", label: split ? "review" : "detail" }, { key: "f", label: "full screen" }, { key: "s", label: "seen" }, { key: "c", label: "feedback" }, { key: "u", label: ui.streamFilter === "unseen" ? "history" : "unseen" }, { key: "m", label: "movies" }, { key: "?", label: "help" }, { key: "q", label: "quit" }]
|
|
650
|
+
: ui.pane === "frictionStep"
|
|
651
|
+
? [{ key: "esc", label: "back" }, { key: "← →", label: "steps" }, { key: "[ ]", label: "images" }, { key: "f", label: "full screen" }]
|
|
652
|
+
: ui.pane === "frictionLog"
|
|
653
|
+
? [{ key: "esc", label: "back" }, { key: "↑↓", label: "steps" }, { key: "⏎", label: "open step" }, { key: "[ ]", label: "runs" }, { key: "p", label: "prompt" }, { key: "s", label: "seen" }]
|
|
654
|
+
: [{ key: "↑↓", label: "move" }, { key: "⏎", label: "open" }, { key: "s", label: "seen" }, { key: "u", label: ui.frictionFilter === "unseen" ? "history" : "unseen" }, { key: "?", label: "help" }, { key: "q", label: "quit" }];
|
|
655
|
+
let body;
|
|
656
|
+
if (ui.help) {
|
|
657
|
+
body = _jsx(HelpOverlay, { width: columns, height: bodyHeight + 1 });
|
|
658
|
+
}
|
|
659
|
+
else if (ui.takeover?.kind === "shot" && activeShot) {
|
|
660
|
+
const siblings = reviewSiblings(shots, activeShot);
|
|
661
|
+
body = (_jsx(ReviewTakeover, { shot: activeShot, position: { index: siblings.findIndex((shot) => shot.path === activeShot.path) + 1, count: siblings.length }, width: columns, height: bodyHeight + 1, composer: ui.composer, onComposerSubmit: (text) => void sendFeedback(activeShot, text), onComposerCancel: () => patch({ composer: false }), playback: ui.playback, onPlayback: updatePlayback, playing: ui.inlinePlayer, busy: ui.busy, error: ui.error, zoom: ui.zoom, panX: ui.panX, panY: ui.panY }));
|
|
662
|
+
}
|
|
663
|
+
else if (ui.takeover?.kind === "step" && selectedLog && selectedRun && selectedRun.steps.length > 0) {
|
|
664
|
+
body = (_jsx(FrictionStepTakeover, { log: selectedLog, run: selectedRun, stepIndex: Math.min(ui.stepIndex, selectedRun.steps.length - 1), imageIndex: ui.imageIndex, width: columns, height: bodyHeight + 1, mode: "takeover" }));
|
|
665
|
+
}
|
|
666
|
+
else if (ui.pane === "settings") {
|
|
667
|
+
body = _jsx(SettingsPane, { roots: storeState.roots, width: columns, height: bodyHeight + 1 });
|
|
668
|
+
}
|
|
669
|
+
else if (ui.tab === "shots") {
|
|
670
|
+
const detail = activeShot ? (_jsx(DetailPane, { shot: activeShot, position: { index: shots.findIndex((shot) => shot.path === activeShot.path) + 1, count: shots.length }, width: paneWidth, height: bodyHeight, mode: split ? "pane" : "page", composer: ui.composer, onComposerSubmit: (text) => void sendFeedback(activeShot, text), onComposerCancel: () => patch({ composer: false }), inlinePlayer: ui.inlinePlayer, playback: ui.playback, onPlayback: updatePlayback, busy: ui.busy, error: ui.error, zoom: ui.zoom, panX: ui.panX, panY: ui.panY })) : (_jsx(Box, { flexShrink: 0, width: paneWidth, height: bodyHeight, alignItems: "center", justifyContent: "center", children: _jsx(Text, { color: theme.muted, children: "Select a frame to review it here" }) }));
|
|
671
|
+
if (!split && ui.pane === "detail") {
|
|
672
|
+
body = (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: bodyHeight + 1, children: [_jsx(Box, { flexShrink: 0, height: 1 }), detail] }));
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
body = (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: bodyHeight + 1, children: [_jsx(Box, { flexShrink: 0, flexDirection: "row", width: columns, height: 1, children: _jsx(FilterBar, { filter: ui.streamFilter, moviesOnly: ui.moviesOnly, counts: counts, width: listWidth, bulkBusy: ui.bulkBusy }) }), _jsxs(Box, { flexShrink: 0, flexDirection: "row", width: columns, height: bodyHeight, children: [_jsx(StreamList, { items: items, collapsed: ui.collapsed, cursor: cursor, scrollTop: scrollTop, width: listWidth, height: listHeight, filter: ui.streamFilter, moviesOnly: ui.moviesOnly, counts: counts, focused: true, scanning: storeState.scanning, hasRoots: storeState.roots.length > 0, totalShots: shots.length, bulkBusy: ui.bulkBusy }), split ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexShrink: 0, width: 1, height: bodyHeight, flexDirection: "column", children: Array.from({ length: bodyHeight }, (_, index) => (_jsx(Text, { color: theme.faint, children: "\u2502" }, index))) }), detail] })) : null] })] }));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else if (ui.pane === "frictionStep" && selectedLog && selectedRun && selectedRun.steps.length > 0) {
|
|
679
|
+
body = (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: bodyHeight + 1, children: [_jsx(Box, { flexShrink: 0, height: 1 }), _jsx(FrictionStepDetail, { log: selectedLog, run: selectedRun, stepIndex: Math.min(ui.stepIndex, selectedRun.steps.length - 1), imageIndex: ui.imageIndex, width: columns, height: bodyHeight, mode: "page" })] }));
|
|
680
|
+
}
|
|
681
|
+
else if (ui.pane === "frictionLog" && selectedLog) {
|
|
682
|
+
body = (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: bodyHeight + 1, children: [_jsx(Box, { flexShrink: 0, height: 1 }), _jsx(FrictionLogDetail, { log: selectedLog, run: selectedRun, stepCursor: ui.stepCursor, promptOpen: ui.promptOpen, prompt: ui.prompt, width: columns, height: bodyHeight })] }));
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
body = (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: bodyHeight + 1, children: [_jsx(FrictionFilterBar, { filter: ui.frictionFilter, counts: frictionCounts, width: columns }), _jsx(FrictionList, { logs: frictionFiltered, cursor: frictionCursor, scrollTop: frictionTop, width: columns, height: listHeight, filter: ui.frictionFilter, counts: frictionCounts, focused: true, total: frictionLogs.length, now: now })] }));
|
|
686
|
+
}
|
|
687
|
+
const shotsUnseen = counts.pending;
|
|
688
|
+
const showTabs = !ui.takeover && !ui.help && ui.pane !== "settings";
|
|
689
|
+
const rowsUsed = FRICTION_ROW_HEIGHT; // keep import used for row-based paging parity
|
|
690
|
+
void rowsUsed;
|
|
691
|
+
return (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { flexShrink: 0, height: 1, width: columns, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u25CF Astroshots" }), _jsxs(Text, { color: status.color, children: [" ", status.text] }), storeState.unreadCount > 0 ? _jsxs(Text, { color: theme.amber, children: [" ", storeState.unreadCount, " new"] }) : null, storeState.error ? _jsxs(Text, { color: theme.red, children: [" ", truncate(storeState.error, 40)] }) : null] }), _jsxs(Text, { color: theme.muted, children: [storeState.treeCount, " trees \u00B7 ", storeState.roots.length, " ", storeState.roots.length === 1 ? "root" : "roots", storeState.watching ? "" : " · not watching"] })] }), _jsx(Box, { flexShrink: 0, height: 1, width: columns, paddingX: 1, children: showTabs ? (_jsxs(Text, { children: [_jsx(Text, { color: ui.tab === "shots" ? theme.text : theme.muted, bold: ui.tab === "shots", inverse: ui.tab === "shots", children: " 1 Shots " }), shotsUnseen > 0 ? _jsx(Text, { color: theme.amber, bold: true, children: ` ${shotsUnseen}` }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: ui.tab === "frictionLogs" ? theme.text : theme.muted, bold: ui.tab === "frictionLogs", inverse: ui.tab === "frictionLogs", children: " 2 Friction Logs " }), frictionCounts.pending > 0 ? _jsx(Text, { color: theme.amber, bold: true, children: ` ${frictionCounts.pending}` }) : null] })) : (_jsx(Text, { color: theme.muted, children: ui.help ? "Help" : ui.pane === "settings" ? "Settings" : ui.takeover?.kind === "shot" ? "Full-screen review" : "Friction step review" })) }), body, ui.toast ? _jsx(Toast, { message: ui.toast.message, width: columns }) : _jsx(HintBar, { hints: hints, width: columns })] }));
|
|
692
|
+
}
|
|
693
|
+
export function frictionLogDirectory(log) {
|
|
694
|
+
return path.dirname(log.directory);
|
|
695
|
+
}
|