@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.
- package/README.md +242 -0
- package/bunfig.toml +7 -0
- package/package.json +42 -0
- package/packages/core/__tests__/concurrency.test.ts +111 -0
- package/packages/core/__tests__/helpers.ts +60 -0
- package/packages/core/__tests__/integration/git-status.test.ts +62 -0
- package/packages/core/__tests__/integration/scanner.test.ts +140 -0
- package/packages/core/__tests__/ocn.test.ts +164 -0
- package/packages/core/package.json +13 -0
- package/packages/core/src/cache.ts +31 -0
- package/packages/core/src/concurrency.ts +44 -0
- package/packages/core/src/devpad.ts +61 -0
- package/packages/core/src/git-graph.ts +54 -0
- package/packages/core/src/git-stats.ts +201 -0
- package/packages/core/src/git-status.ts +316 -0
- package/packages/core/src/github.ts +286 -0
- package/packages/core/src/index.ts +58 -0
- package/packages/core/src/ocn.ts +74 -0
- package/packages/core/src/scanner.ts +118 -0
- package/packages/core/src/types.ts +199 -0
- package/packages/core/src/watcher.ts +128 -0
- package/packages/core/src/worktree.ts +80 -0
- package/packages/core/tsconfig.json +5 -0
- package/packages/render/bunfig.toml +8 -0
- package/packages/render/jsx-runtime.d.ts +3 -0
- package/packages/render/package.json +18 -0
- package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +780 -0
- package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +304 -0
- package/packages/render/src/components/git-graph.tsx +127 -0
- package/packages/render/src/components/help-overlay.tsx +108 -0
- package/packages/render/src/components/index.ts +7 -0
- package/packages/render/src/components/repo-list.tsx +127 -0
- package/packages/render/src/components/stats-panel.tsx +116 -0
- package/packages/render/src/components/status-badge.tsx +70 -0
- package/packages/render/src/components/status-bar.tsx +56 -0
- package/packages/render/src/components/widget-container.tsx +286 -0
- package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +326 -0
- package/packages/render/src/components/widgets/branch-list.tsx +93 -0
- package/packages/render/src/components/widgets/commit-activity.tsx +112 -0
- package/packages/render/src/components/widgets/devpad-milestones.tsx +88 -0
- package/packages/render/src/components/widgets/devpad-tasks.tsx +81 -0
- package/packages/render/src/components/widgets/file-changes.tsx +78 -0
- package/packages/render/src/components/widgets/git-status.tsx +125 -0
- package/packages/render/src/components/widgets/github-ci.tsx +98 -0
- package/packages/render/src/components/widgets/github-issues.tsx +101 -0
- package/packages/render/src/components/widgets/github-prs.tsx +119 -0
- package/packages/render/src/components/widgets/github-release.tsx +73 -0
- package/packages/render/src/components/widgets/index.ts +12 -0
- package/packages/render/src/components/widgets/recent-commits.tsx +64 -0
- package/packages/render/src/components/widgets/registry.ts +23 -0
- package/packages/render/src/components/widgets/repo-meta.tsx +80 -0
- package/packages/render/src/config/index.ts +104 -0
- package/packages/render/src/lib/__tests__/fetch-context.test.ts +200 -0
- package/packages/render/src/lib/__tests__/widget-grid.test.ts +665 -0
- package/packages/render/src/lib/actions.ts +68 -0
- package/packages/render/src/lib/fetch-context.ts +102 -0
- package/packages/render/src/lib/filter.ts +94 -0
- package/packages/render/src/lib/format.ts +36 -0
- package/packages/render/src/lib/use-devpad.ts +167 -0
- package/packages/render/src/lib/use-github.ts +75 -0
- package/packages/render/src/lib/widget-grid.ts +204 -0
- package/packages/render/src/lib/widget-state.ts +96 -0
- package/packages/render/src/overview.tsx +16 -0
- package/packages/render/src/screens/index.ts +1 -0
- package/packages/render/src/screens/main-screen.tsx +410 -0
- package/packages/render/src/theme/index.ts +37 -0
- package/packages/render/tsconfig.json +9 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ok, err, type Result } from "@f0rbit/corpus";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import type { OcnStatus, OcnSessionStatus } from "./types";
|
|
5
|
+
|
|
6
|
+
export type OcnError =
|
|
7
|
+
| { kind: "state_dir_not_found" }
|
|
8
|
+
| { kind: "read_failed"; path: string; cause: string };
|
|
9
|
+
|
|
10
|
+
interface OcnStateFile {
|
|
11
|
+
pid: number;
|
|
12
|
+
directory: string;
|
|
13
|
+
project: string;
|
|
14
|
+
status: OcnSessionStatus;
|
|
15
|
+
last_transition: string;
|
|
16
|
+
session_id: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const VALID_STATUSES: Set<string> = new Set(["idle", "busy", "prompting", "error"]);
|
|
20
|
+
|
|
21
|
+
function isAlive(pid: number): boolean {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isValidState(data: unknown): data is OcnStateFile {
|
|
31
|
+
if (typeof data !== "object" || data === null) return false;
|
|
32
|
+
const obj = data as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
typeof obj.pid === "number" &&
|
|
35
|
+
typeof obj.directory === "string" &&
|
|
36
|
+
typeof obj.status === "string" &&
|
|
37
|
+
VALID_STATUSES.has(obj.status) &&
|
|
38
|
+
typeof obj.session_id === "string"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readOcnStates(): Promise<Result<Map<string, OcnStatus>, OcnError>> {
|
|
43
|
+
const state_dir = process.env.OCN_STATE_DIR ?? join(process.env.HOME ?? "~", ".local", "state", "ocn");
|
|
44
|
+
const map = new Map<string, OcnStatus>();
|
|
45
|
+
|
|
46
|
+
let entries: string[];
|
|
47
|
+
try {
|
|
48
|
+
const dir_entries = await readdir(state_dir);
|
|
49
|
+
entries = dir_entries.filter((e) => e.endsWith(".json"));
|
|
50
|
+
} catch {
|
|
51
|
+
// State dir doesn't exist — ocn not installed or no sessions. Not an error.
|
|
52
|
+
return ok(map);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const file_path = join(state_dir, entry);
|
|
57
|
+
try {
|
|
58
|
+
const data = await Bun.file(file_path).json();
|
|
59
|
+
if (!isValidState(data)) continue;
|
|
60
|
+
if (!isAlive(data.pid)) continue;
|
|
61
|
+
|
|
62
|
+
map.set(data.directory, {
|
|
63
|
+
pid: data.pid,
|
|
64
|
+
status: data.status,
|
|
65
|
+
session_id: data.session_id,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// Malformed JSON or read error — skip silently
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return ok(map);
|
|
74
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readdir, stat, lstat } from "node:fs/promises";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import { ok, err, try_catch_async, format_error, type Result } from "@f0rbit/corpus";
|
|
4
|
+
import type { RepoNode } from "./types";
|
|
5
|
+
|
|
6
|
+
export type ScanError =
|
|
7
|
+
| { kind: "invalid_path"; path: string; message: string }
|
|
8
|
+
| { kind: "permission_denied"; path: string }
|
|
9
|
+
| { kind: "scan_failed"; path: string; cause: string };
|
|
10
|
+
|
|
11
|
+
async function isGitRepo(dirPath: string): Promise<boolean> {
|
|
12
|
+
const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
|
|
13
|
+
cwd: dirPath,
|
|
14
|
+
stdout: "pipe",
|
|
15
|
+
stderr: "pipe",
|
|
16
|
+
});
|
|
17
|
+
const code = await proc.exited;
|
|
18
|
+
return code === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function detectType(dirPath: string): Promise<"repo" | "worktree" | null> {
|
|
22
|
+
const git_path = join(dirPath, ".git");
|
|
23
|
+
const result = await try_catch_async(
|
|
24
|
+
() => lstat(git_path),
|
|
25
|
+
() => null,
|
|
26
|
+
);
|
|
27
|
+
if (!result.ok || result.value === null) return null;
|
|
28
|
+
|
|
29
|
+
const stats = result.value;
|
|
30
|
+
if (stats.isFile()) return "worktree";
|
|
31
|
+
if (stats.isDirectory()) return "repo";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function walkDirectory(
|
|
36
|
+
dirPath: string,
|
|
37
|
+
current_depth: number,
|
|
38
|
+
max_depth: number,
|
|
39
|
+
ignore: string[],
|
|
40
|
+
): Promise<Result<RepoNode[], ScanError>> {
|
|
41
|
+
if (current_depth > max_depth) return ok([]);
|
|
42
|
+
|
|
43
|
+
const entries_result = await try_catch_async(
|
|
44
|
+
() => readdir(dirPath, { withFileTypes: true }),
|
|
45
|
+
(e) => {
|
|
46
|
+
const msg = format_error(e);
|
|
47
|
+
if (msg.includes("EACCES") || msg.includes("permission"))
|
|
48
|
+
return { kind: "permission_denied" as const, path: dirPath };
|
|
49
|
+
return { kind: "scan_failed" as const, path: dirPath, cause: msg };
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (!entries_result.ok) return entries_result;
|
|
54
|
+
|
|
55
|
+
const dirs = entries_result.value
|
|
56
|
+
.filter((e) => e.isDirectory() && !ignore.some((pattern) => e.name.includes(pattern)))
|
|
57
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
|
+
|
|
59
|
+
const results = await Promise.all(
|
|
60
|
+
dirs.map(async (entry) => {
|
|
61
|
+
const full_path = join(dirPath, entry.name);
|
|
62
|
+
const repo_type = await detectType(full_path);
|
|
63
|
+
const is_repo = repo_type !== null && (await isGitRepo(full_path));
|
|
64
|
+
|
|
65
|
+
const children_result = await walkDirectory(full_path, current_depth + 1, max_depth, ignore);
|
|
66
|
+
if (!children_result.ok) return null;
|
|
67
|
+
|
|
68
|
+
const children = children_result.value;
|
|
69
|
+
|
|
70
|
+
if (is_repo) {
|
|
71
|
+
return {
|
|
72
|
+
name: entry.name,
|
|
73
|
+
path: full_path,
|
|
74
|
+
type: repo_type,
|
|
75
|
+
status: null,
|
|
76
|
+
worktrees: [],
|
|
77
|
+
children,
|
|
78
|
+
depth: current_depth,
|
|
79
|
+
expanded: current_depth <= 1,
|
|
80
|
+
} as RepoNode;
|
|
81
|
+
} else if (children.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
name: entry.name,
|
|
84
|
+
path: full_path,
|
|
85
|
+
type: "directory" as const,
|
|
86
|
+
status: null,
|
|
87
|
+
worktrees: [],
|
|
88
|
+
children,
|
|
89
|
+
depth: current_depth,
|
|
90
|
+
expanded: current_depth <= 1,
|
|
91
|
+
} as RepoNode;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return ok(results.filter((n): n is RepoNode => n !== null));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function scanDirectory(
|
|
101
|
+
root: string,
|
|
102
|
+
options: { depth: number; ignore: string[] },
|
|
103
|
+
): Promise<Result<RepoNode[], ScanError>> {
|
|
104
|
+
const root_stat = await try_catch_async(
|
|
105
|
+
() => stat(root),
|
|
106
|
+
(e) => ({
|
|
107
|
+
kind: "invalid_path" as const,
|
|
108
|
+
path: root,
|
|
109
|
+
message: format_error(e),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (!root_stat.ok) return root_stat;
|
|
114
|
+
if (!root_stat.value.isDirectory())
|
|
115
|
+
return err({ kind: "invalid_path", path: root, message: "Not a directory" });
|
|
116
|
+
|
|
117
|
+
return walkDirectory(root, 0, options.depth, options.ignore);
|
|
118
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Git file change
|
|
2
|
+
export interface GitFileChange {
|
|
3
|
+
path: string;
|
|
4
|
+
status: "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted";
|
|
5
|
+
staged: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Branch info
|
|
9
|
+
export interface BranchInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
is_current: boolean;
|
|
12
|
+
upstream: string | null;
|
|
13
|
+
ahead: number;
|
|
14
|
+
behind: number;
|
|
15
|
+
last_commit_time: number; // unix timestamp
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Stash entry
|
|
19
|
+
export interface StashEntry {
|
|
20
|
+
index: number;
|
|
21
|
+
message: string;
|
|
22
|
+
date: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Recent commit
|
|
26
|
+
export interface RecentCommit {
|
|
27
|
+
hash: string;
|
|
28
|
+
message: string;
|
|
29
|
+
author: string;
|
|
30
|
+
time: number; // unix timestamp
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// OpenCode session status (from ocn)
|
|
34
|
+
export type OcnSessionStatus = "idle" | "busy" | "prompting" | "error";
|
|
35
|
+
|
|
36
|
+
export interface OcnStatus {
|
|
37
|
+
pid: number;
|
|
38
|
+
status: OcnSessionStatus;
|
|
39
|
+
session_id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Health status
|
|
43
|
+
export type HealthStatus = "clean" | "dirty" | "ahead" | "behind" | "diverged" | "conflict";
|
|
44
|
+
|
|
45
|
+
// Full repo status
|
|
46
|
+
export interface RepoStatus {
|
|
47
|
+
// Identity
|
|
48
|
+
path: string;
|
|
49
|
+
name: string;
|
|
50
|
+
display_path: string; // relative to scan root
|
|
51
|
+
|
|
52
|
+
// Current state
|
|
53
|
+
current_branch: string;
|
|
54
|
+
head_commit: string;
|
|
55
|
+
head_message: string;
|
|
56
|
+
head_time: number;
|
|
57
|
+
|
|
58
|
+
// Tracking
|
|
59
|
+
remote_url: string | null;
|
|
60
|
+
ahead: number;
|
|
61
|
+
behind: number;
|
|
62
|
+
|
|
63
|
+
// Working tree
|
|
64
|
+
modified_count: number;
|
|
65
|
+
staged_count: number;
|
|
66
|
+
untracked_count: number;
|
|
67
|
+
conflict_count: number;
|
|
68
|
+
changes: GitFileChange[];
|
|
69
|
+
|
|
70
|
+
// Stash
|
|
71
|
+
stash_count: number;
|
|
72
|
+
stashes: StashEntry[];
|
|
73
|
+
|
|
74
|
+
// Branches
|
|
75
|
+
branches: BranchInfo[];
|
|
76
|
+
local_branch_count: number;
|
|
77
|
+
remote_branch_count: number;
|
|
78
|
+
|
|
79
|
+
// Metadata
|
|
80
|
+
tags: string[];
|
|
81
|
+
total_commits: number;
|
|
82
|
+
repo_size_bytes: number;
|
|
83
|
+
contributor_count: number;
|
|
84
|
+
|
|
85
|
+
// Recent activity
|
|
86
|
+
recent_commits: RecentCommit[];
|
|
87
|
+
|
|
88
|
+
// Commit activity (populated by fetchDetails, not initial scan)
|
|
89
|
+
commit_activity: { daily_counts: number[]; total_this_week: number; total_last_week: number } | null;
|
|
90
|
+
|
|
91
|
+
// OpenCode session status (from ocn)
|
|
92
|
+
ocn_status: OcnStatus | null;
|
|
93
|
+
|
|
94
|
+
// Derived
|
|
95
|
+
is_clean: boolean;
|
|
96
|
+
health: HealthStatus;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Worktree info
|
|
100
|
+
export interface WorktreeInfo {
|
|
101
|
+
path: string;
|
|
102
|
+
branch: string;
|
|
103
|
+
head: string;
|
|
104
|
+
is_bare: boolean;
|
|
105
|
+
is_main: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Git graph output
|
|
109
|
+
export interface GitGraphOutput {
|
|
110
|
+
lines: string[];
|
|
111
|
+
total_lines: number;
|
|
112
|
+
repo_path: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Repo tree node
|
|
116
|
+
export interface RepoNode {
|
|
117
|
+
name: string;
|
|
118
|
+
path: string;
|
|
119
|
+
type: "directory" | "repo" | "worktree";
|
|
120
|
+
status: RepoStatus | null;
|
|
121
|
+
worktrees: WorktreeInfo[];
|
|
122
|
+
children: RepoNode[];
|
|
123
|
+
depth: number;
|
|
124
|
+
expanded: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Widget system
|
|
128
|
+
export type WidgetId =
|
|
129
|
+
| "git-status"
|
|
130
|
+
| "recent-commits"
|
|
131
|
+
| "branch-list"
|
|
132
|
+
| "github-prs"
|
|
133
|
+
| "github-issues"
|
|
134
|
+
| "github-ci"
|
|
135
|
+
| "devpad-tasks"
|
|
136
|
+
| "devpad-milestones"
|
|
137
|
+
| "repo-meta"
|
|
138
|
+
| "file-changes"
|
|
139
|
+
| "commit-activity"
|
|
140
|
+
| "github-release";
|
|
141
|
+
|
|
142
|
+
export interface WidgetConfig {
|
|
143
|
+
id: WidgetId;
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
priority: number;
|
|
146
|
+
collapsed: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type WidgetSpan = "full" | "half" | "third" | "auto";
|
|
150
|
+
|
|
151
|
+
export interface WidgetSizeHint {
|
|
152
|
+
span: WidgetSpan;
|
|
153
|
+
min_height: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface WidgetRenderProps {
|
|
157
|
+
width: number;
|
|
158
|
+
focused: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Config
|
|
162
|
+
export interface OverviewConfig {
|
|
163
|
+
scan_dirs: string[];
|
|
164
|
+
depth: number;
|
|
165
|
+
refresh_interval: number;
|
|
166
|
+
layout: {
|
|
167
|
+
left_width_pct: number;
|
|
168
|
+
graph_height_pct: number;
|
|
169
|
+
};
|
|
170
|
+
sort: "name" | "status" | "last-commit";
|
|
171
|
+
filter: "all" | "dirty" | "clean" | "ahead" | "behind";
|
|
172
|
+
ignore: string[];
|
|
173
|
+
actions: {
|
|
174
|
+
ggi: string;
|
|
175
|
+
editor: string;
|
|
176
|
+
sessionizer: string | null;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Default config factory
|
|
181
|
+
export function defaultConfig(): OverviewConfig {
|
|
182
|
+
return {
|
|
183
|
+
scan_dirs: ["~/dev"],
|
|
184
|
+
depth: 3,
|
|
185
|
+
refresh_interval: 30,
|
|
186
|
+
layout: {
|
|
187
|
+
left_width_pct: 35,
|
|
188
|
+
graph_height_pct: 45,
|
|
189
|
+
},
|
|
190
|
+
sort: "name",
|
|
191
|
+
filter: "all",
|
|
192
|
+
ignore: ["node_modules", ".git"],
|
|
193
|
+
actions: {
|
|
194
|
+
ggi: "ggi",
|
|
195
|
+
editor: "$EDITOR",
|
|
196
|
+
sessionizer: null,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFile, lstat } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export interface WatcherOptions {
|
|
6
|
+
/** Debounce interval in ms (default 500) */
|
|
7
|
+
debounce_ms?: number;
|
|
8
|
+
/** Callback when a repo changes */
|
|
9
|
+
on_change: (repoPath: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RepoWatcher {
|
|
13
|
+
/** Start watching a list of repo paths */
|
|
14
|
+
watch(repoPaths: string[]): void;
|
|
15
|
+
/** Stop watching all repos */
|
|
16
|
+
close(): void;
|
|
17
|
+
/** Add a single repo to watch */
|
|
18
|
+
add(repoPath: string): void;
|
|
19
|
+
/** Remove a single repo from watching */
|
|
20
|
+
remove(repoPath: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveGitDir(repo_path: string): Promise<string | null> {
|
|
24
|
+
const git_path = join(repo_path, ".git");
|
|
25
|
+
try {
|
|
26
|
+
const stats = await lstat(git_path);
|
|
27
|
+
if (stats.isDirectory()) return git_path;
|
|
28
|
+
if (stats.isFile()) {
|
|
29
|
+
const content = await readFile(git_path, "utf-8");
|
|
30
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
31
|
+
const target = match?.[1]?.trim();
|
|
32
|
+
if (!target) return null;
|
|
33
|
+
return target.startsWith("/") ? target : join(repo_path, target);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tryWatch(
|
|
42
|
+
target: string,
|
|
43
|
+
options: { recursive?: boolean },
|
|
44
|
+
callback: () => void,
|
|
45
|
+
): FSWatcher | null {
|
|
46
|
+
try {
|
|
47
|
+
const watcher = watch(target, options, callback);
|
|
48
|
+
watcher.on("error", () => {});
|
|
49
|
+
return watcher;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createRepoWatcher(options: WatcherOptions): RepoWatcher {
|
|
56
|
+
const debounce_ms = options.debounce_ms ?? 500;
|
|
57
|
+
const watchers = new Map<string, FSWatcher[]>();
|
|
58
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
59
|
+
|
|
60
|
+
function debouncedChange(repo_path: string) {
|
|
61
|
+
const existing = timers.get(repo_path);
|
|
62
|
+
if (existing) clearTimeout(existing);
|
|
63
|
+
timers.set(
|
|
64
|
+
repo_path,
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
timers.delete(repo_path);
|
|
67
|
+
options.on_change(repo_path);
|
|
68
|
+
}, debounce_ms),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function addRepo(repo_path: string) {
|
|
73
|
+
if (watchers.has(repo_path)) return;
|
|
74
|
+
|
|
75
|
+
const git_dir = await resolveGitDir(repo_path);
|
|
76
|
+
if (!git_dir) {
|
|
77
|
+
console.warn(`[watcher] skipping ${repo_path}: could not resolve .git directory`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const repo_watchers: FSWatcher[] = [];
|
|
82
|
+
const on_event = () => debouncedChange(repo_path);
|
|
83
|
+
|
|
84
|
+
const index_watcher = tryWatch(join(git_dir, "index"), {}, on_event);
|
|
85
|
+
if (index_watcher) repo_watchers.push(index_watcher);
|
|
86
|
+
|
|
87
|
+
const refs_watcher = tryWatch(join(git_dir, "refs"), { recursive: true }, on_event);
|
|
88
|
+
if (refs_watcher) repo_watchers.push(refs_watcher);
|
|
89
|
+
|
|
90
|
+
if (repo_watchers.length === 0) {
|
|
91
|
+
console.warn(`[watcher] skipping ${repo_path}: no watchable targets`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
watchers.set(repo_path, repo_watchers);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeRepo(repo_path: string) {
|
|
99
|
+
const repo_watchers = watchers.get(repo_path);
|
|
100
|
+
if (repo_watchers) {
|
|
101
|
+
repo_watchers.forEach((w) => w.close());
|
|
102
|
+
watchers.delete(repo_path);
|
|
103
|
+
}
|
|
104
|
+
const timer = timers.get(repo_path);
|
|
105
|
+
if (timer) {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
timers.delete(repo_path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
watch(repo_paths: string[]) {
|
|
113
|
+
repo_paths.forEach((p) => addRepo(p));
|
|
114
|
+
},
|
|
115
|
+
close() {
|
|
116
|
+
watchers.forEach((ws) => ws.forEach((w) => w.close()));
|
|
117
|
+
watchers.clear();
|
|
118
|
+
timers.forEach((t) => clearTimeout(t));
|
|
119
|
+
timers.clear();
|
|
120
|
+
},
|
|
121
|
+
add(repo_path: string) {
|
|
122
|
+
addRepo(repo_path);
|
|
123
|
+
},
|
|
124
|
+
remove(repo_path: string) {
|
|
125
|
+
removeRepo(repo_path);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ok, err, type Result } from "@f0rbit/corpus";
|
|
2
|
+
import type { WorktreeInfo } from "./types";
|
|
3
|
+
|
|
4
|
+
export type WorktreeError =
|
|
5
|
+
| { kind: "not_a_repo"; path: string }
|
|
6
|
+
| { kind: "worktree_failed"; path: string; cause: string };
|
|
7
|
+
|
|
8
|
+
async function gitCommand(
|
|
9
|
+
args: string[],
|
|
10
|
+
cwd: string,
|
|
11
|
+
): Promise<Result<string, WorktreeError>> {
|
|
12
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
13
|
+
cwd,
|
|
14
|
+
stdout: "pipe",
|
|
15
|
+
stderr: "pipe",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await proc.exited;
|
|
19
|
+
|
|
20
|
+
if (proc.exitCode !== 0) {
|
|
21
|
+
const stderr = await new Response(proc.stderr).text();
|
|
22
|
+
const is_not_repo =
|
|
23
|
+
stderr.includes("not a git repository") ||
|
|
24
|
+
stderr.includes("not a git repo");
|
|
25
|
+
if (is_not_repo) {
|
|
26
|
+
return err({ kind: "not_a_repo", path: cwd });
|
|
27
|
+
}
|
|
28
|
+
return err({
|
|
29
|
+
kind: "worktree_failed",
|
|
30
|
+
path: cwd,
|
|
31
|
+
cause: stderr.trim(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stdout = await new Response(proc.stdout).text();
|
|
36
|
+
return ok(stdout);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseWorktreeBlock(
|
|
40
|
+
lines: string[],
|
|
41
|
+
is_main: boolean,
|
|
42
|
+
): WorktreeInfo | null {
|
|
43
|
+
const path = lines
|
|
44
|
+
.find((l) => l.startsWith("worktree "))
|
|
45
|
+
?.slice("worktree ".length);
|
|
46
|
+
if (!path) return null;
|
|
47
|
+
|
|
48
|
+
const head_line = lines.find((l) => l.startsWith("HEAD "));
|
|
49
|
+
const head = head_line ? head_line.slice("HEAD ".length, "HEAD ".length + 7) : "0000000";
|
|
50
|
+
|
|
51
|
+
const branch_line = lines.find((l) => l.startsWith("branch "));
|
|
52
|
+
const is_bare = lines.some((l) => l === "bare");
|
|
53
|
+
const branch = branch_line
|
|
54
|
+
? branch_line.slice("branch refs/heads/".length)
|
|
55
|
+
: is_bare
|
|
56
|
+
? "bare"
|
|
57
|
+
: "detached";
|
|
58
|
+
|
|
59
|
+
return { path, branch, head, is_bare, is_main };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function detectWorktrees(
|
|
63
|
+
repoPath: string,
|
|
64
|
+
): Promise<Result<WorktreeInfo[], WorktreeError>> {
|
|
65
|
+
const result = await gitCommand(["worktree", "list", "--porcelain"], repoPath);
|
|
66
|
+
if (!result.ok) return result;
|
|
67
|
+
|
|
68
|
+
const blocks = result.value
|
|
69
|
+
.split("\n\n")
|
|
70
|
+
.map((b) => b.trim())
|
|
71
|
+
.filter((b) => b.length > 0);
|
|
72
|
+
|
|
73
|
+
const worktrees = blocks
|
|
74
|
+
.map((block, i) => parseWorktreeBlock(block.split("\n"), i === 0))
|
|
75
|
+
.filter((w): w is WorktreeInfo => w !== null);
|
|
76
|
+
|
|
77
|
+
if (worktrees.length <= 1) return ok([]);
|
|
78
|
+
|
|
79
|
+
return ok(worktrees);
|
|
80
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@overview/render",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "src/overview.tsx",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "bun run src/overview.tsx",
|
|
7
|
+
"test": "bun test",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@opentui/core": "^0.1.80",
|
|
12
|
+
"@opentui/solid": "^0.1.80",
|
|
13
|
+
"solid-js": "^1.9.11",
|
|
14
|
+
"@overview/core": "workspace:*",
|
|
15
|
+
"@f0rbit/corpus": "link:@f0rbit/corpus",
|
|
16
|
+
"@devpad/api": "link:@devpad/api"
|
|
17
|
+
}
|
|
18
|
+
}
|