@f0rbit/overview 0.1.0

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.
Files changed (68) hide show
  1. package/README.md +242 -0
  2. package/bunfig.toml +7 -0
  3. package/package.json +42 -0
  4. package/packages/core/__tests__/concurrency.test.ts +111 -0
  5. package/packages/core/__tests__/helpers.ts +60 -0
  6. package/packages/core/__tests__/integration/git-status.test.ts +62 -0
  7. package/packages/core/__tests__/integration/scanner.test.ts +140 -0
  8. package/packages/core/__tests__/ocn.test.ts +164 -0
  9. package/packages/core/package.json +13 -0
  10. package/packages/core/src/cache.ts +31 -0
  11. package/packages/core/src/concurrency.ts +44 -0
  12. package/packages/core/src/devpad.ts +61 -0
  13. package/packages/core/src/git-graph.ts +54 -0
  14. package/packages/core/src/git-stats.ts +201 -0
  15. package/packages/core/src/git-status.ts +316 -0
  16. package/packages/core/src/github.ts +286 -0
  17. package/packages/core/src/index.ts +58 -0
  18. package/packages/core/src/ocn.ts +74 -0
  19. package/packages/core/src/scanner.ts +118 -0
  20. package/packages/core/src/types.ts +199 -0
  21. package/packages/core/src/watcher.ts +128 -0
  22. package/packages/core/src/worktree.ts +80 -0
  23. package/packages/core/tsconfig.json +5 -0
  24. package/packages/render/bunfig.toml +8 -0
  25. package/packages/render/jsx-runtime.d.ts +3 -0
  26. package/packages/render/package.json +18 -0
  27. package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +780 -0
  28. package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +304 -0
  29. package/packages/render/src/components/git-graph.tsx +127 -0
  30. package/packages/render/src/components/help-overlay.tsx +108 -0
  31. package/packages/render/src/components/index.ts +7 -0
  32. package/packages/render/src/components/repo-list.tsx +127 -0
  33. package/packages/render/src/components/stats-panel.tsx +116 -0
  34. package/packages/render/src/components/status-badge.tsx +70 -0
  35. package/packages/render/src/components/status-bar.tsx +56 -0
  36. package/packages/render/src/components/widget-container.tsx +286 -0
  37. package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +326 -0
  38. package/packages/render/src/components/widgets/branch-list.tsx +93 -0
  39. package/packages/render/src/components/widgets/commit-activity.tsx +112 -0
  40. package/packages/render/src/components/widgets/devpad-milestones.tsx +88 -0
  41. package/packages/render/src/components/widgets/devpad-tasks.tsx +81 -0
  42. package/packages/render/src/components/widgets/file-changes.tsx +78 -0
  43. package/packages/render/src/components/widgets/git-status.tsx +125 -0
  44. package/packages/render/src/components/widgets/github-ci.tsx +98 -0
  45. package/packages/render/src/components/widgets/github-issues.tsx +101 -0
  46. package/packages/render/src/components/widgets/github-prs.tsx +119 -0
  47. package/packages/render/src/components/widgets/github-release.tsx +73 -0
  48. package/packages/render/src/components/widgets/index.ts +12 -0
  49. package/packages/render/src/components/widgets/recent-commits.tsx +64 -0
  50. package/packages/render/src/components/widgets/registry.ts +23 -0
  51. package/packages/render/src/components/widgets/repo-meta.tsx +80 -0
  52. package/packages/render/src/config/index.ts +104 -0
  53. package/packages/render/src/lib/__tests__/fetch-context.test.ts +200 -0
  54. package/packages/render/src/lib/__tests__/widget-grid.test.ts +665 -0
  55. package/packages/render/src/lib/actions.ts +68 -0
  56. package/packages/render/src/lib/fetch-context.ts +102 -0
  57. package/packages/render/src/lib/filter.ts +94 -0
  58. package/packages/render/src/lib/format.ts +36 -0
  59. package/packages/render/src/lib/use-devpad.ts +167 -0
  60. package/packages/render/src/lib/use-github.ts +75 -0
  61. package/packages/render/src/lib/widget-grid.ts +204 -0
  62. package/packages/render/src/lib/widget-state.ts +96 -0
  63. package/packages/render/src/overview.tsx +16 -0
  64. package/packages/render/src/screens/index.ts +1 -0
  65. package/packages/render/src/screens/main-screen.tsx +410 -0
  66. package/packages/render/src/theme/index.ts +37 -0
  67. package/packages/render/tsconfig.json +9 -0
  68. package/tsconfig.json +23 -0
@@ -0,0 +1,410 @@
1
+ import { createSignal, createEffect, createMemo, onMount, onCleanup, Show } from "solid-js";
2
+ import { useKeyboard, useTerminalDimensions, useRenderer } from "@opentui/solid";
3
+ import type { RepoNode, GitGraphOutput, OverviewConfig, HealthStatus, WidgetConfig } from "@overview/core";
4
+ import { scanAndCollect, captureGraph, collectStats, collectStatus, createRepoWatcher, collectCommitActivity } from "@overview/core";
5
+ import { RepoList, GitGraph, WidgetContainer, StatusBar, HelpOverlay, type AppMode } from "../components";
6
+ import { filterTree, sortTree, nextFilter, nextSort, type SortMode, type FilterMode } from "../lib/filter";
7
+ import { createFetchContext } from "../lib/fetch-context";
8
+ import { launchGgi, launchEditor, launchSessionizer } from "../lib/actions";
9
+ import { loadWidgetState, saveWidgetState, defaultWidgetConfig, getWidgetState, updateWidgetState } from "../lib/widget-state";
10
+ import { theme } from "../theme";
11
+
12
+ interface MainScreenProps {
13
+ config: OverviewConfig;
14
+ }
15
+
16
+ function countNodes(nodes: RepoNode[]): number {
17
+ return nodes.reduce(
18
+ (acc, n) => acc + (n.type === "directory" ? countNodes(n.children) : 1),
19
+ 0,
20
+ );
21
+ }
22
+
23
+ function countByHealth(nodes: RepoNode[], pred: (h: HealthStatus) => boolean): number {
24
+ return nodes.reduce((acc, n) => {
25
+ if (n.type === "directory") return acc + countByHealth(n.children, pred);
26
+ return acc + (n.status && pred(n.status.health) ? 1 : 0);
27
+ }, 0);
28
+ }
29
+
30
+ function collectRepoPaths(nodes: RepoNode[]): string[] {
31
+ return nodes.flatMap((n) =>
32
+ n.type === "directory" ? collectRepoPaths(n.children) : [n.path],
33
+ );
34
+ }
35
+
36
+ function updateRepoStatus(nodes: RepoNode[], repoPath: string, status: RepoNode["status"]): void {
37
+ for (const n of nodes) {
38
+ if (n.type === "directory") {
39
+ updateRepoStatus(n.children, repoPath, status);
40
+ } else if (n.path === repoPath) {
41
+ n.status = status;
42
+ }
43
+ }
44
+ }
45
+
46
+ export function MainScreen(props: MainScreenProps) {
47
+ const [repos, setRepos] = createSignal<RepoNode[]>([]);
48
+ const [selectedNode, setSelectedNode] = createSignal<RepoNode | null>(null);
49
+ const [graph, setGraph] = createSignal<GitGraphOutput | null>(null);
50
+ const [graphLoading, setGraphLoading] = createSignal(false);
51
+ const [statsLoading, setStatsLoading] = createSignal(false);
52
+ const [scanning, setScanning] = createSignal(true);
53
+ const [mode, setMode] = createSignal<AppMode>("NORMAL");
54
+ const [focusPanel, setFocusPanel] = createSignal<"list" | "graph" | "stats">("list");
55
+ const [message, setMessage] = createSignal<string | null>(null);
56
+ const [sortMode, setSortMode] = createSignal<SortMode>(props.config.sort);
57
+ const [filterMode, setFilterMode] = createSignal<FilterMode>(props.config.filter);
58
+ const [showHelp, setShowHelp] = createSignal(false);
59
+ const [widgetConfigs, setWidgetConfigs] = createSignal<WidgetConfig[]>(defaultWidgetConfig());
60
+ const [repoVersion, setRepoVersion] = createSignal(0);
61
+
62
+ const renderer = useRenderer();
63
+
64
+ const dimensions = useTerminalDimensions();
65
+ const leftWidth = () => 40;
66
+
67
+ const rightPanelWidth = createMemo(() => {
68
+ const w = dimensions().width - leftWidth() - 2; // subtract left panel border
69
+ return Math.max(10, w);
70
+ });
71
+
72
+ const processedRepos = createMemo(() => {
73
+ repoVersion(); // track version for reactivity
74
+ let result = repos();
75
+ result = filterTree(result, filterMode());
76
+ result = sortTree(result, sortMode());
77
+ return result;
78
+ });
79
+
80
+ const statusMessage = createMemo(() => {
81
+ const parts: string[] = [];
82
+ if (filterMode() !== "all") parts.push(`filter: ${filterMode()}`);
83
+ if (sortMode() !== "name") parts.push(`sort: ${sortMode()}`);
84
+ return parts.length > 0 ? parts.join(" ") : message();
85
+ });
86
+
87
+ const repoCount = createMemo(() => countNodes(repos()));
88
+ const dirtyCount = createMemo(() => countByHealth(repos(), (h) => h !== "clean"));
89
+ const aheadCount = createMemo(() => countByHealth(repos(), (h) => h === "ahead" || h === "diverged"));
90
+
91
+ const widgetSummary = createMemo(() => {
92
+ const configs = widgetConfigs();
93
+ const enabled = configs.filter((c) => c.enabled).length;
94
+ return `${enabled}/${configs.length} widgets`;
95
+ });
96
+
97
+ async function handleWidgetConfigChange(configs: WidgetConfig[]) {
98
+ setWidgetConfigs(configs);
99
+ const state = { ...getWidgetState(), widgets: configs };
100
+ updateWidgetState(state);
101
+ await saveWidgetState(state);
102
+ }
103
+
104
+ let _details_request_id = 0;
105
+ let _details_timer: ReturnType<typeof setTimeout> | undefined;
106
+
107
+ async function fetchDetails(node: RepoNode | null) {
108
+ if (!node || node.type === "directory") {
109
+ setGraph(null);
110
+ setGraphLoading(false);
111
+ setStatsLoading(false);
112
+ return;
113
+ }
114
+
115
+ const my_request_id = _details_request_id;
116
+
117
+ setGraphLoading(true);
118
+ setStatsLoading(true);
119
+
120
+ const [graphResult, statsResult, activityResult] = await Promise.all([
121
+ captureGraph(node.path),
122
+ collectStats(node.path),
123
+ collectCommitActivity(node.path),
124
+ ]);
125
+
126
+ // Stale check — a newer request was issued while we were awaiting
127
+ if (my_request_id !== _details_request_id) return;
128
+
129
+ if (graphResult.ok) setGraph(graphResult.value);
130
+ else setGraph(null);
131
+
132
+ if (statsResult.ok && node.status) {
133
+ node.status.tags = statsResult.value.tags;
134
+ node.status.total_commits = statsResult.value.total_commits;
135
+ node.status.repo_size_bytes = statsResult.value.repo_size_bytes;
136
+ node.status.contributor_count = statsResult.value.contributor_count;
137
+ node.status.recent_commits = statsResult.value.recent_commits;
138
+ }
139
+
140
+ if (activityResult.ok && node.status) {
141
+ node.status.commit_activity = activityResult.value;
142
+ }
143
+
144
+ setGraphLoading(false);
145
+ setStatsLoading(false);
146
+ }
147
+
148
+ createEffect(() => {
149
+ const node = selectedNode();
150
+ clearTimeout(_details_timer);
151
+ if (!node || node.type === "directory") {
152
+ _details_request_id++;
153
+ setGraph(null);
154
+ setGraphLoading(false);
155
+ setStatsLoading(false);
156
+ return;
157
+ }
158
+ // Show loading state immediately
159
+ setGraphLoading(true);
160
+ setStatsLoading(true);
161
+ // Debounce the actual fetch
162
+ _details_request_id++;
163
+ _details_timer = setTimeout(() => {
164
+ fetchDetails(node);
165
+ }, 250);
166
+ });
167
+
168
+ const watcher = createRepoWatcher({
169
+ debounce_ms: 500,
170
+ on_change: (repoPath) => {
171
+ collectStatus(repoPath, props.config.scan_dirs[0]!).then((result) => {
172
+ if (result.ok) {
173
+ updateRepoStatus(repos(), repoPath, result.value);
174
+ setRepoVersion(v => v + 1);
175
+ }
176
+ });
177
+ },
178
+ });
179
+
180
+ async function performScan() {
181
+ setScanning(true);
182
+ for (const dir of props.config.scan_dirs) {
183
+ const result = await scanAndCollect(dir, {
184
+ depth: props.config.depth,
185
+ ignore: props.config.ignore,
186
+ });
187
+ if (result.ok) {
188
+ setRepos(result.value);
189
+ watcher.watch(collectRepoPaths(result.value));
190
+ }
191
+ }
192
+ setScanning(false);
193
+ }
194
+
195
+ onMount(() => {
196
+ performScan();
197
+ loadWidgetState().then((result) => {
198
+ if (result.ok) {
199
+ setWidgetConfigs(result.value.widgets);
200
+ updateWidgetState(result.value);
201
+ }
202
+ });
203
+ });
204
+
205
+ onCleanup(() => {
206
+ clearTimeout(_details_timer);
207
+ watcher.close();
208
+ });
209
+
210
+ const FOCUS_ORDER = ["list", "graph", "stats"] as const;
211
+
212
+ function cycleFocus() {
213
+ const current = focusPanel();
214
+ const idx = FOCUS_ORDER.indexOf(current);
215
+ setFocusPanel(FOCUS_ORDER[(idx + 1) % FOCUS_ORDER.length]!);
216
+ }
217
+
218
+ function handleSelect(node: RepoNode) {
219
+ setSelectedNode(node);
220
+ }
221
+
222
+ useKeyboard((key) => {
223
+ const m = mode();
224
+
225
+ if (key.name === "tab") {
226
+ cycleFocus();
227
+ return;
228
+ }
229
+
230
+ if (m === "NORMAL") {
231
+ switch (key.name) {
232
+ case "q":
233
+ case "escape":
234
+ process.exit(0);
235
+ break;
236
+ case "return": {
237
+ const node = selectedNode();
238
+ if (node && node.type !== "directory") {
239
+ setMode("DETAIL");
240
+ setFocusPanel("graph");
241
+ }
242
+ break;
243
+ }
244
+ case "l": {
245
+ const node = selectedNode();
246
+ if (node && node.type !== "directory") {
247
+ setMode("DETAIL");
248
+ setFocusPanel("graph");
249
+ }
250
+ break;
251
+ }
252
+ case "r":
253
+ _details_request_id++;
254
+ clearTimeout(_details_timer);
255
+ fetchDetails(selectedNode());
256
+ break;
257
+ case "R":
258
+ performScan();
259
+ break;
260
+ case "f":
261
+ setFilterMode(nextFilter(filterMode()));
262
+ break;
263
+ case "s":
264
+ setSortMode(nextSort(sortMode()));
265
+ break;
266
+ case "o": {
267
+ const node = selectedNode();
268
+ if (node?.type === "repo" || node?.type === "worktree") {
269
+ launchEditor(node.path, props.config.actions.editor, {
270
+ onSuspend: () => renderer.suspend(),
271
+ onResume: () => renderer.resume(),
272
+ });
273
+ }
274
+ break;
275
+ }
276
+ case "t": {
277
+ const node = selectedNode();
278
+ if ((node?.type === "repo" || node?.type === "worktree") && props.config.actions.sessionizer) {
279
+ launchSessionizer(node.path, props.config.actions.sessionizer, {
280
+ onSuspend: () => renderer.suspend(),
281
+ onResume: () => renderer.resume(),
282
+ });
283
+ }
284
+ break;
285
+ }
286
+ }
287
+
288
+ if (key.raw === "?") {
289
+ setShowHelp(!showHelp());
290
+ }
291
+ return;
292
+ }
293
+
294
+ if (m === "DETAIL") {
295
+ switch (key.name) {
296
+ case "q":
297
+ case "escape":
298
+ setMode("NORMAL");
299
+ setFocusPanel("list");
300
+ break;
301
+ case "h":
302
+ if (focusPanel() === "graph") {
303
+ setMode("NORMAL");
304
+ setFocusPanel("list");
305
+ } else {
306
+ setFocusPanel("graph");
307
+ }
308
+ break;
309
+ case "l":
310
+ setFocusPanel("stats");
311
+ break;
312
+ case "g": {
313
+ const node = selectedNode();
314
+ if (node?.type === "repo" || node?.type === "worktree") {
315
+ launchGgi(node.path, props.config.actions.ggi, {
316
+ onSuspend: () => renderer.suspend(),
317
+ onResume: () => renderer.resume(),
318
+ });
319
+ }
320
+ break;
321
+ }
322
+ case "o": {
323
+ const node = selectedNode();
324
+ if (node?.type === "repo" || node?.type === "worktree") {
325
+ launchEditor(node.path, props.config.actions.editor, {
326
+ onSuspend: () => renderer.suspend(),
327
+ onResume: () => renderer.resume(),
328
+ });
329
+ }
330
+ break;
331
+ }
332
+ case "t": {
333
+ const node = selectedNode();
334
+ if ((node?.type === "repo" || node?.type === "worktree") && props.config.actions.sessionizer) {
335
+ launchSessionizer(node.path, props.config.actions.sessionizer, {
336
+ onSuspend: () => renderer.suspend(),
337
+ onResume: () => renderer.resume(),
338
+ });
339
+ }
340
+ break;
341
+ }
342
+ case "r":
343
+ _details_request_id++;
344
+ clearTimeout(_details_timer);
345
+ fetchDetails(selectedNode());
346
+ break;
347
+ }
348
+ }
349
+ });
350
+
351
+ return (
352
+ <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.bg}>
353
+ {/* Header */}
354
+ <box height={1} width="100%" backgroundColor={theme.bg_dark} paddingLeft={1}>
355
+ <text fg={theme.blue}>overview</text>
356
+ <box flexGrow={1} />
357
+ <text fg={theme.fg_dim} content={`${props.config.scan_dirs[0]} — ${repoCount()} repos`} />
358
+ <Show when={scanning()}>
359
+ <text fg={theme.yellow}> scanning...</text>
360
+ </Show>
361
+ </box>
362
+
363
+ {/* Main content */}
364
+ <box flexDirection="row" flexGrow={1}>
365
+ {/* Left panel */}
366
+ <box width={leftWidth()} flexDirection="column" borderStyle="rounded" borderColor={focusPanel() === "list" ? theme.border_highlight : theme.border}>
367
+ <RepoList
368
+ repos={processedRepos()}
369
+ focused={focusPanel() === "list"}
370
+ onSelect={handleSelect}
371
+ />
372
+ </box>
373
+
374
+ {/* Right panels */}
375
+ <box flexDirection="column" flexGrow={1}>
376
+ <GitGraph
377
+ graph={graph()}
378
+ repoName={selectedNode()?.name ?? ""}
379
+ loading={graphLoading()}
380
+ focused={focusPanel() === "graph"}
381
+ height="50%"
382
+ />
383
+ <WidgetContainer
384
+ status={selectedNode()?.status ?? null}
385
+ repoName={selectedNode()?.name ?? ""}
386
+ loading={statsLoading()}
387
+ focused={focusPanel() === "stats"}
388
+ height="50%"
389
+ availableWidth={rightPanelWidth()}
390
+ widgetConfigs={widgetConfigs()}
391
+ onWidgetConfigChange={handleWidgetConfigChange}
392
+ />
393
+ </box>
394
+ </box>
395
+
396
+ {/* Status bar */}
397
+ <StatusBar
398
+ mode={mode()}
399
+ repoCount={repoCount()}
400
+ dirtyCount={dirtyCount()}
401
+ aheadCount={aheadCount()}
402
+ scanning={scanning()}
403
+ message={statusMessage()}
404
+ widgetSummary={widgetSummary()}
405
+ />
406
+
407
+ <HelpOverlay visible={showHelp()} onClose={() => setShowHelp(false)} />
408
+ </box>
409
+ );
410
+ }
@@ -0,0 +1,37 @@
1
+ export const theme = {
2
+ // Base
3
+ bg: "#1a1b26",
4
+ bg_dark: "#16161e",
5
+ bg_highlight: "#283457",
6
+ fg: "#c0caf5",
7
+ fg_dark: "#a9b1d6",
8
+ fg_dim: "#565f89",
9
+
10
+ // Accents
11
+ blue: "#7aa2f7",
12
+ cyan: "#7dcfff",
13
+ green: "#9ece6a",
14
+ yellow: "#e0af68",
15
+ red: "#f7768e",
16
+ magenta: "#bb9af7",
17
+ orange: "#ff9e64",
18
+
19
+ // UI
20
+ border: "#3b4261",
21
+ border_highlight: "#7aa2f7",
22
+ selection: "#283457",
23
+ comment: "#565f89",
24
+
25
+ // Status colors
26
+ status: {
27
+ clean: "#9ece6a",
28
+ ahead: "#e0af68",
29
+ behind: "#7dcfff",
30
+ modified: "#7aa2f7",
31
+ conflict: "#f7768e",
32
+ untracked: "#565f89",
33
+ stash: "#bb9af7",
34
+ },
35
+ } as const;
36
+
37
+ export type Theme = typeof theme;
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "preserve",
5
+ "jsxImportSource": "@opentui/solid"
6
+ },
7
+ "include": ["src/**/*", "jsx-runtime.d.ts"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "moduleResolution": "bundler",
5
+ "target": "esnext",
6
+ "module": "esnext",
7
+ "jsx": "preserve",
8
+ "jsxImportSource": "solid-js",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "paths": {
18
+ "@overview/core": ["./packages/core/src/index.ts"],
19
+ "@overview/core/*": ["./packages/core/src/*"]
20
+ }
21
+ },
22
+ "include": ["packages/core/src/**/*", "packages/render/src/**/*"]
23
+ }