santree 0.5.6 → 0.6.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/dist/commands/dashboard.js +210 -33
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/helpers/squirrel.d.ts +2 -0
- package/dist/commands/helpers/squirrel.js +12 -0
- package/dist/commands/worktree/commit.d.ts +9 -1
- package/dist/commands/worktree/commit.js +58 -14
- package/dist/lib/ai.d.ts +26 -0
- package/dist/lib/ai.js +53 -0
- package/dist/lib/claude-todos.d.ts +37 -0
- package/dist/lib/claude-todos.js +98 -0
- package/dist/lib/dashboard/DetailPanel.js +99 -9
- package/dist/lib/dashboard/IssueList.js +2 -0
- package/dist/lib/dashboard/MultilineTextArea.js +14 -1
- package/dist/lib/dashboard/Overlays.d.ts +5 -0
- package/dist/lib/dashboard/Overlays.js +75 -2
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
- package/dist/lib/dashboard/ReviewList.js +12 -15
- package/dist/lib/dashboard/data.js +158 -7
- package/dist/lib/dashboard/types.d.ts +45 -5
- package/dist/lib/dashboard/types.js +40 -0
- package/dist/lib/diff-parse.d.ts +25 -0
- package/dist/lib/diff-parse.js +60 -0
- package/dist/lib/git.d.ts +22 -0
- package/dist/lib/git.js +41 -0
- package/dist/lib/github.d.ts +6 -0
- package/dist/lib/github.js +29 -0
- package/dist/lib/open-url.d.ts +10 -0
- package/dist/lib/open-url.js +20 -0
- package/dist/lib/squirrel-loader.d.ts +9 -0
- package/dist/lib/squirrel-loader.js +322 -0
- package/dist/lib/trackers/index.d.ts +13 -0
- package/dist/lib/trackers/index.js +19 -0
- package/package.json +1 -1
- package/prompts/fill-commit.njk +79 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, getDiffShortstatAsync, } from "../git.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
|
|
2
|
+
import { runAsync } from "../exec.js";
|
|
3
|
+
import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
|
|
4
|
+
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
|
|
5
|
+
import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
|
|
4
6
|
export async function loadDashboardData(repoRoot) {
|
|
5
7
|
// Fetch issues and worktrees in parallel
|
|
6
8
|
const tracker = getIssueTracker(repoRoot);
|
|
@@ -50,16 +52,39 @@ export async function loadDashboardData(repoRoot) {
|
|
|
50
52
|
sessState = null;
|
|
51
53
|
}
|
|
52
54
|
const ss = sessState?.state ?? null;
|
|
55
|
+
const storedId = metadata[issue.identifier]?.session_id ?? null;
|
|
56
|
+
// Verify the session is still resumable. Claude Code clears old
|
|
57
|
+
// transcript files (or `/clear` mints a new ID), leaving our stored
|
|
58
|
+
// session_id pointing at nothing. Without this check the dashboard
|
|
59
|
+
// offers `[↵] Resume`, which fails with "No conversation found with
|
|
60
|
+
// session ID". `findClaudeSessionCwd` also returns the real cwd
|
|
61
|
+
// where the session lives — needed because project conventions
|
|
62
|
+
// (direnv, shell init) sometimes cd into a subdir before Claude
|
|
63
|
+
// was launched, so resume must run from there. On a miss we drop
|
|
64
|
+
// the stored ID from metadata so the next refresh skips this work.
|
|
65
|
+
const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
|
|
66
|
+
let sessionId = storedId;
|
|
67
|
+
if (storedId && !sessionCwd) {
|
|
68
|
+
clearSessionId(repoRoot, issue.identifier);
|
|
69
|
+
sessionId = null;
|
|
70
|
+
}
|
|
71
|
+
// Hide stale todos when the session has exited or its file is gone —
|
|
72
|
+
// the on-disk todos file outlives the process and showing them
|
|
73
|
+
// would lie about state.
|
|
74
|
+
const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
|
|
53
75
|
worktreeInfo = {
|
|
54
76
|
path: wt.path,
|
|
55
77
|
branch: wt.branch,
|
|
56
78
|
dirty: Boolean(gitStatusOutput),
|
|
57
79
|
commitsAhead: ahead,
|
|
58
|
-
|
|
80
|
+
commitsBehind: null,
|
|
81
|
+
sessionId,
|
|
82
|
+
sessionCwd,
|
|
59
83
|
gitStatus: gitStatusOutput,
|
|
60
84
|
sessionState: ss === "exited" ? null : ss,
|
|
61
85
|
sessionMessage: sessState?.message ?? null,
|
|
62
86
|
diffStats: shortstat,
|
|
87
|
+
claudeTodos,
|
|
63
88
|
};
|
|
64
89
|
prInfo = pr;
|
|
65
90
|
if (pr) {
|
|
@@ -112,6 +137,14 @@ export async function loadDashboardData(repoRoot) {
|
|
|
112
137
|
sessState = null;
|
|
113
138
|
}
|
|
114
139
|
const ss = sessState?.state ?? null;
|
|
140
|
+
const storedId = metadata[tid]?.session_id ?? null;
|
|
141
|
+
const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
|
|
142
|
+
let sessionId = storedId;
|
|
143
|
+
if (storedId && !sessionCwd) {
|
|
144
|
+
clearSessionId(repoRoot, tid);
|
|
145
|
+
sessionId = null;
|
|
146
|
+
}
|
|
147
|
+
const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
|
|
115
148
|
return {
|
|
116
149
|
issue: {
|
|
117
150
|
identifier: tid,
|
|
@@ -130,11 +163,14 @@ export async function loadDashboardData(repoRoot) {
|
|
|
130
163
|
branch: wt.branch,
|
|
131
164
|
dirty: Boolean(gitStatusOutput),
|
|
132
165
|
commitsAhead: ahead,
|
|
133
|
-
|
|
166
|
+
commitsBehind: null,
|
|
167
|
+
sessionId,
|
|
168
|
+
sessionCwd,
|
|
134
169
|
gitStatus: gitStatusOutput,
|
|
135
170
|
sessionState: ss === "exited" ? null : ss,
|
|
136
171
|
sessionMessage: sessState?.message ?? null,
|
|
137
172
|
diffStats: shortstat,
|
|
173
|
+
claudeTodos,
|
|
138
174
|
},
|
|
139
175
|
pr,
|
|
140
176
|
checks: checksInfo,
|
|
@@ -235,8 +271,75 @@ export async function loadDashboardData(repoRoot) {
|
|
|
235
271
|
return result;
|
|
236
272
|
}
|
|
237
273
|
const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
|
|
274
|
+
// Synthesize a "Main repo" row at the very top so users can commit /
|
|
275
|
+
// view diffs / inspect drift on whatever branch their main checkout
|
|
276
|
+
// happens to be on. The row uses the same WorktreeInfo shape but with
|
|
277
|
+
// state.type === "main" so the renderer can differentiate.
|
|
278
|
+
const mainEntry = await buildMainEntry(repoRoot);
|
|
279
|
+
if (mainEntry) {
|
|
280
|
+
groups.unshift({
|
|
281
|
+
name: "Main repo",
|
|
282
|
+
id: null,
|
|
283
|
+
statusGroups: [{ name: "Main", type: "main", issues: [mainEntry] }],
|
|
284
|
+
});
|
|
285
|
+
flatIssues.unshift(mainEntry);
|
|
286
|
+
}
|
|
238
287
|
return { groups, flatIssues };
|
|
239
288
|
}
|
|
289
|
+
/** Build the synthetic dashboard row for the main repo checkout — the
|
|
290
|
+
* non-worktree clone that the user typically commits master/main from.
|
|
291
|
+
* Returns null only if we can't read the current branch (e.g. detached
|
|
292
|
+
* HEAD with no commits). */
|
|
293
|
+
async function buildMainEntry(repoRoot) {
|
|
294
|
+
const branch = (await runAsync(`git -C "${repoRoot}" rev-parse --abbrev-ref HEAD`))?.trim();
|
|
295
|
+
if (!branch || branch === "HEAD")
|
|
296
|
+
return null;
|
|
297
|
+
// `commitsAhead` here is "how many local commits haven't been pushed",
|
|
298
|
+
// `commitsBehind` is "how many upstream commits I haven't pulled" —
|
|
299
|
+
// both relative to origin/<currentBranch>. If there's no upstream,
|
|
300
|
+
// both come back as 0; the renderer treats that as "in sync".
|
|
301
|
+
const [gitStatusOutput, ahead, behind, shortstat] = await Promise.all([
|
|
302
|
+
getGitStatusAsync(repoRoot),
|
|
303
|
+
getCommitsAheadAsync(repoRoot, `origin/${branch}`),
|
|
304
|
+
getCommitsBehindAsync(repoRoot, branch),
|
|
305
|
+
// Diff vs origin so the +/- numbers reflect unpushed work.
|
|
306
|
+
getDiffShortstatAsync(repoRoot, `origin/${branch}`),
|
|
307
|
+
]);
|
|
308
|
+
const defaultBranch = getDefaultBranch();
|
|
309
|
+
const isDefault = branch === defaultBranch;
|
|
310
|
+
const title = isDefault ? `${branch} (default)` : branch;
|
|
311
|
+
return {
|
|
312
|
+
issue: {
|
|
313
|
+
identifier: branch.toUpperCase(),
|
|
314
|
+
title,
|
|
315
|
+
description: null,
|
|
316
|
+
url: "",
|
|
317
|
+
priority: 0,
|
|
318
|
+
priorityLabel: "None",
|
|
319
|
+
state: { name: "Main", type: "main" },
|
|
320
|
+
labels: [],
|
|
321
|
+
projectId: null,
|
|
322
|
+
projectName: "Main repo",
|
|
323
|
+
},
|
|
324
|
+
worktree: {
|
|
325
|
+
path: repoRoot,
|
|
326
|
+
branch,
|
|
327
|
+
dirty: Boolean(gitStatusOutput),
|
|
328
|
+
commitsAhead: ahead,
|
|
329
|
+
commitsBehind: behind,
|
|
330
|
+
sessionId: null,
|
|
331
|
+
sessionCwd: null,
|
|
332
|
+
gitStatus: gitStatusOutput,
|
|
333
|
+
sessionState: null,
|
|
334
|
+
sessionMessage: null,
|
|
335
|
+
diffStats: shortstat,
|
|
336
|
+
claudeTodos: null,
|
|
337
|
+
},
|
|
338
|
+
pr: null,
|
|
339
|
+
checks: null,
|
|
340
|
+
reviews: null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
240
343
|
export async function loadReviewsData(repoRoot) {
|
|
241
344
|
const repo = await getRepoNameAsync();
|
|
242
345
|
if (!repo)
|
|
@@ -251,13 +354,22 @@ export async function loadReviewsData(repoRoot) {
|
|
|
251
354
|
branchToWt.set(wt.branch, { path: wt.path, branch: wt.branch });
|
|
252
355
|
}
|
|
253
356
|
const metadata = readAllMetadata(repoRoot);
|
|
357
|
+
// Trackers worth trying when extracting a ticket ID from a PR's branch.
|
|
358
|
+
// We hit the reviews tab to look at OTHER people's PRs — their branches
|
|
359
|
+
// may follow a different convention than the current repo's active
|
|
360
|
+
// tracker (e.g. a GitHub-tracker repo reviewing PRs from a Linear team
|
|
361
|
+
// where branches encode `TEAM-1234`). The active tracker comes first;
|
|
362
|
+
// `getCandidateTrackers` appends Linear as a fallback when GitHub is
|
|
363
|
+
// active and Linear creds exist.
|
|
364
|
+
const candidates = getCandidateTrackers(repoRoot);
|
|
254
365
|
// Enrich each PR in parallel
|
|
255
366
|
const enriched = await Promise.all(prs.map(async (pr) => {
|
|
256
|
-
const [view, checks, reviews, comments] = await Promise.all([
|
|
367
|
+
const [view, checks, reviews, comments, authorName] = await Promise.all([
|
|
257
368
|
getPRViewAsync(pr.number),
|
|
258
369
|
getPRChecksAsync(String(pr.number)),
|
|
259
370
|
getPRReviewsAsync(String(pr.number)),
|
|
260
371
|
getPRConversationCommentsAsync(String(pr.number)),
|
|
372
|
+
getGitHubUserNameAsync(pr.author.login),
|
|
261
373
|
]);
|
|
262
374
|
// Check if we have a local worktree for this PR's branch
|
|
263
375
|
let worktreeInfo = null;
|
|
@@ -278,19 +390,56 @@ export async function loadReviewsData(repoRoot) {
|
|
|
278
390
|
sessState = null;
|
|
279
391
|
}
|
|
280
392
|
const ss = sessState?.state ?? null;
|
|
393
|
+
const storedId = ticketId ? (metadata[ticketId]?.session_id ?? null) : null;
|
|
394
|
+
const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
|
|
395
|
+
let sessionId = storedId;
|
|
396
|
+
if (storedId && ticketId && !sessionCwd) {
|
|
397
|
+
clearSessionId(repoRoot, ticketId);
|
|
398
|
+
sessionId = null;
|
|
399
|
+
}
|
|
400
|
+
const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
|
|
281
401
|
worktreeInfo = {
|
|
282
402
|
path: wt.path,
|
|
283
403
|
branch: wt.branch,
|
|
284
404
|
dirty: Boolean(gitStatusOutput),
|
|
285
405
|
commitsAhead: ahead,
|
|
286
|
-
|
|
406
|
+
commitsBehind: null,
|
|
407
|
+
sessionId,
|
|
408
|
+
sessionCwd,
|
|
287
409
|
gitStatus: gitStatusOutput,
|
|
288
410
|
sessionState: ss === "exited" ? null : ss,
|
|
289
411
|
sessionMessage: sessState?.message ?? null,
|
|
290
412
|
diffStats: shortstat,
|
|
413
|
+
claudeTodos,
|
|
291
414
|
};
|
|
292
415
|
}
|
|
293
416
|
}
|
|
417
|
+
// Resolve linked tracker issue. Try inputs in priority order:
|
|
418
|
+
// branch first (most likely to encode the canonical ID), then PR
|
|
419
|
+
// title (e.g. `[MSG-4084] …`). Each candidate tracker's
|
|
420
|
+
// `extractIdFromBranch` regex is unanchored and works on any
|
|
421
|
+
// text, not just branches — that's the (slightly misnamed but
|
|
422
|
+
// load-bearing) reuse this fallback depends on.
|
|
423
|
+
// Stops at the first hit. Failures stay silent.
|
|
424
|
+
let ticket = null;
|
|
425
|
+
const idInputs = [branch, pr.title].filter((s) => Boolean(s));
|
|
426
|
+
outer: for (const cand of candidates) {
|
|
427
|
+
for (const text of idInputs) {
|
|
428
|
+
const ticketId = cand.extractIdFromBranch(text);
|
|
429
|
+
if (!ticketId)
|
|
430
|
+
continue;
|
|
431
|
+
try {
|
|
432
|
+
const res = await cand.getIssue(ticketId, repoRoot);
|
|
433
|
+
if (res.ok) {
|
|
434
|
+
ticket = res.value;
|
|
435
|
+
break outer;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// swallow — try the next candidate / input
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
294
443
|
return {
|
|
295
444
|
pr,
|
|
296
445
|
body: view?.body ?? null,
|
|
@@ -303,6 +452,8 @@ export async function loadReviewsData(repoRoot) {
|
|
|
303
452
|
reviews,
|
|
304
453
|
comments,
|
|
305
454
|
worktree: worktreeInfo,
|
|
455
|
+
ticket,
|
|
456
|
+
authorName,
|
|
306
457
|
};
|
|
307
458
|
}));
|
|
308
459
|
return { flatReviews: enriched };
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type { ClaudeTodo } from "../claude-todos.js";
|
|
3
|
+
import type { AssignedIssue, Issue } from "../trackers/types.js";
|
|
4
|
+
export type { AssignedIssue, Issue } from "../trackers/types.js";
|
|
4
5
|
export interface WorktreeInfo {
|
|
5
6
|
path: string;
|
|
6
7
|
branch: string;
|
|
7
8
|
dirty: boolean;
|
|
8
9
|
commitsAhead: number;
|
|
10
|
+
/** How many commits HEAD is behind origin/<branch>. Populated for the
|
|
11
|
+
* synthetic main-repo row so users can see how stale their local
|
|
12
|
+
* checkout is. Null on ticket worktrees (the answer is uninteresting
|
|
13
|
+
* because the ticket branch usually has no upstream yet). */
|
|
14
|
+
commitsBehind: number | null;
|
|
9
15
|
sessionId: string | null;
|
|
16
|
+
/** Real-path cwd from which `claude --resume <sessionId>` actually
|
|
17
|
+
* succeeds. Usually equals `path`, but project conventions (direnv,
|
|
18
|
+
* shell init) sometimes cd into a subdirectory like `backend/canary`
|
|
19
|
+
* before Claude is launched, in which case the session is resumable
|
|
20
|
+
* only from there. The dashboard prepends `cd <sessionCwd>` to the
|
|
21
|
+
* resume command so it survives tmux send-keys cwd drift. Null when
|
|
22
|
+
* sessionId is null or the underlying file can't be located. */
|
|
23
|
+
sessionCwd: string | null;
|
|
10
24
|
gitStatus: string;
|
|
11
25
|
sessionState: "waiting" | "idle" | "active" | null;
|
|
12
26
|
sessionMessage: string | null;
|
|
@@ -15,6 +29,7 @@ export interface WorktreeInfo {
|
|
|
15
29
|
insertions: number;
|
|
16
30
|
deletions: number;
|
|
17
31
|
} | null;
|
|
32
|
+
claudeTodos: ClaudeTodo[] | null;
|
|
18
33
|
}
|
|
19
34
|
export interface DashboardIssue {
|
|
20
35
|
issue: AssignedIssue;
|
|
@@ -48,9 +63,17 @@ export interface EnrichedReviewPR {
|
|
|
48
63
|
reviews: PRReview[] | null;
|
|
49
64
|
comments: PRConversationComment[] | null;
|
|
50
65
|
worktree: WorktreeInfo | null;
|
|
66
|
+
/** Linked tracker issue, parsed from the PR's branch name first, then
|
|
67
|
+
* falling back to the PR title. Null when no recognizable ID is found,
|
|
68
|
+
* the tracker fails, or the issue is gone. The detail panel renders a
|
|
69
|
+
* Ticket section only when this is set. */
|
|
70
|
+
ticket: Issue | null;
|
|
71
|
+
/** GitHub display name for the PR author (`name` field on /users/:login).
|
|
72
|
+
* Null when the user hasn't set one or the lookup failed. */
|
|
73
|
+
authorName: string | null;
|
|
51
74
|
}
|
|
52
75
|
export type DashboardTab = "issues" | "reviews";
|
|
53
|
-
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | null;
|
|
76
|
+
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | null;
|
|
54
77
|
export type DiffFileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?";
|
|
55
78
|
export interface DiffFile {
|
|
56
79
|
path: string;
|
|
@@ -60,7 +83,7 @@ export interface DiffFile {
|
|
|
60
83
|
workingStatus?: string;
|
|
61
84
|
isUntracked?: boolean;
|
|
62
85
|
}
|
|
63
|
-
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
86
|
+
export type CommitPhase = "idle" | "confirm-stage" | "choose-mode" | "filling" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
64
87
|
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
|
|
65
88
|
export interface DashboardState {
|
|
66
89
|
activeTab: DashboardTab;
|
|
@@ -107,6 +130,12 @@ export interface DashboardState {
|
|
|
107
130
|
diffWorktreePath: string | null;
|
|
108
131
|
diffBaseBranch: string | null;
|
|
109
132
|
diffMergeBase: string | null;
|
|
133
|
+
/** When set, the diff is sourced from `gh pr diff <n>` rather than local
|
|
134
|
+
* `git diff` — used by the reviews tab when a PR has no local worktree.
|
|
135
|
+
* The file list and per-file content are parsed from the unified diff
|
|
136
|
+
* once and held in `diffPRContentByPath`. */
|
|
137
|
+
diffPRNumber: number | null;
|
|
138
|
+
diffPRContentByPath: Record<string, string>;
|
|
110
139
|
diffFiles: DiffFile[];
|
|
111
140
|
diffFileIndex: number;
|
|
112
141
|
diffFileScrollOffset: number;
|
|
@@ -167,7 +196,9 @@ export type DashboardAction = {
|
|
|
167
196
|
type: "DELETE_DONE";
|
|
168
197
|
} | {
|
|
169
198
|
type: "COMMIT_START";
|
|
170
|
-
|
|
199
|
+
/** Null when committing on a non-ticket branch (e.g. the main
|
|
200
|
+
* repo row) — the commit message gets no `[ID]` prefix. */
|
|
201
|
+
ticketId: string | null;
|
|
171
202
|
worktreePath: string;
|
|
172
203
|
branch: string;
|
|
173
204
|
gitStatus: string;
|
|
@@ -255,6 +286,15 @@ export type DashboardAction = {
|
|
|
255
286
|
ticketId: string;
|
|
256
287
|
worktreePath: string;
|
|
257
288
|
baseBranch: string;
|
|
289
|
+
} | {
|
|
290
|
+
type: "DIFF_OPEN_PR";
|
|
291
|
+
label: string;
|
|
292
|
+
prNumber: number;
|
|
293
|
+
baseBranch: string;
|
|
294
|
+
} | {
|
|
295
|
+
type: "DIFF_PR_LOADED";
|
|
296
|
+
files: DiffFile[];
|
|
297
|
+
contentByPath: Record<string, string>;
|
|
258
298
|
} | {
|
|
259
299
|
type: "DIFF_FILES_LOADED";
|
|
260
300
|
files: DiffFile[];
|
|
@@ -44,6 +44,8 @@ export const initialState = {
|
|
|
44
44
|
diffWorktreePath: null,
|
|
45
45
|
diffBaseBranch: null,
|
|
46
46
|
diffMergeBase: null,
|
|
47
|
+
diffPRNumber: null,
|
|
48
|
+
diffPRContentByPath: {},
|
|
47
49
|
diffFiles: [],
|
|
48
50
|
diffFileIndex: 0,
|
|
49
51
|
diffFileScrollOffset: 0,
|
|
@@ -288,6 +290,8 @@ export function reducer(state, action) {
|
|
|
288
290
|
diffWorktreePath: action.worktreePath,
|
|
289
291
|
diffBaseBranch: action.baseBranch,
|
|
290
292
|
diffMergeBase: null,
|
|
293
|
+
diffPRNumber: null,
|
|
294
|
+
diffPRContentByPath: {},
|
|
291
295
|
diffFiles: [],
|
|
292
296
|
diffFileIndex: 0,
|
|
293
297
|
diffFileScrollOffset: 0,
|
|
@@ -299,6 +303,40 @@ export function reducer(state, action) {
|
|
|
299
303
|
diffPendingDiscard: null,
|
|
300
304
|
diffRefreshTick: 0,
|
|
301
305
|
};
|
|
306
|
+
case "DIFF_OPEN_PR":
|
|
307
|
+
return {
|
|
308
|
+
...state,
|
|
309
|
+
overlay: "diff",
|
|
310
|
+
diffTicketId: action.label,
|
|
311
|
+
diffWorktreePath: null,
|
|
312
|
+
diffBaseBranch: action.baseBranch,
|
|
313
|
+
diffMergeBase: null,
|
|
314
|
+
diffPRNumber: action.prNumber,
|
|
315
|
+
diffPRContentByPath: {},
|
|
316
|
+
diffFiles: [],
|
|
317
|
+
diffFileIndex: 0,
|
|
318
|
+
diffFileScrollOffset: 0,
|
|
319
|
+
diffContent: null,
|
|
320
|
+
diffContentScrollOffset: 0,
|
|
321
|
+
diffLoadingFiles: true,
|
|
322
|
+
// Hold loadingContent on through the DIFF_PR_LOADED dispatch so the
|
|
323
|
+
// right pane shows "Loading diff..." until the content effect fires
|
|
324
|
+
// (avoids a one-frame "(empty diff)" flash between file-list
|
|
325
|
+
// arrival and content selection).
|
|
326
|
+
diffLoadingContent: true,
|
|
327
|
+
diffError: null,
|
|
328
|
+
diffPendingDiscard: null,
|
|
329
|
+
diffRefreshTick: 0,
|
|
330
|
+
};
|
|
331
|
+
case "DIFF_PR_LOADED":
|
|
332
|
+
return {
|
|
333
|
+
...state,
|
|
334
|
+
diffFiles: action.files,
|
|
335
|
+
diffPRContentByPath: action.contentByPath,
|
|
336
|
+
diffFileIndex: 0,
|
|
337
|
+
diffLoadingFiles: false,
|
|
338
|
+
diffError: null,
|
|
339
|
+
};
|
|
302
340
|
case "DIFF_FILES_LOADED": {
|
|
303
341
|
// Preserve the user's selection across reloads (after stage/unstage/
|
|
304
342
|
// discard) by matching the previously-selected file's path. Falls back
|
|
@@ -396,6 +434,8 @@ export function reducer(state, action) {
|
|
|
396
434
|
diffWorktreePath: null,
|
|
397
435
|
diffBaseBranch: null,
|
|
398
436
|
diffMergeBase: null,
|
|
437
|
+
diffPRNumber: null,
|
|
438
|
+
diffPRContentByPath: {},
|
|
399
439
|
diffFiles: [],
|
|
400
440
|
diffFileIndex: 0,
|
|
401
441
|
diffFileScrollOffset: 0,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DiffFile } from "./dashboard/types.js";
|
|
2
|
+
export interface ParsedDiff {
|
|
3
|
+
files: DiffFile[];
|
|
4
|
+
contentByPath: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse a unified diff blob (e.g. `gh pr diff <n>`) into per-file records
|
|
8
|
+
* suitable for DiffOverlay. Each section starts at `^diff --git a/PATH b/PATH`
|
|
9
|
+
* and runs until the next such header (or EOF).
|
|
10
|
+
*
|
|
11
|
+
* Status is derived from the per-file metadata git emits:
|
|
12
|
+
* - `new file mode …` → "A"
|
|
13
|
+
* - `deleted file mode …` → "D"
|
|
14
|
+
* - `rename from / rename to` → "R"
|
|
15
|
+
* - everything else → "M"
|
|
16
|
+
*
|
|
17
|
+
* For renames, `path` is the new path and `oldPath` is preserved on the file
|
|
18
|
+
* record so DiffOverlay's tree shows the destination location. Deletes use the
|
|
19
|
+
* old path (the new side doesn't exist).
|
|
20
|
+
*
|
|
21
|
+
* Robustness: an unparseable header (a `diff --git` line that doesn't match the
|
|
22
|
+
* expected `a/PATH b/PATH` shape) is skipped rather than fatal — we'd rather
|
|
23
|
+
* lose one entry than crash the overlay.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseUnifiedDiff(text: string): ParsedDiff;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a unified diff blob (e.g. `gh pr diff <n>`) into per-file records
|
|
3
|
+
* suitable for DiffOverlay. Each section starts at `^diff --git a/PATH b/PATH`
|
|
4
|
+
* and runs until the next such header (or EOF).
|
|
5
|
+
*
|
|
6
|
+
* Status is derived from the per-file metadata git emits:
|
|
7
|
+
* - `new file mode …` → "A"
|
|
8
|
+
* - `deleted file mode …` → "D"
|
|
9
|
+
* - `rename from / rename to` → "R"
|
|
10
|
+
* - everything else → "M"
|
|
11
|
+
*
|
|
12
|
+
* For renames, `path` is the new path and `oldPath` is preserved on the file
|
|
13
|
+
* record so DiffOverlay's tree shows the destination location. Deletes use the
|
|
14
|
+
* old path (the new side doesn't exist).
|
|
15
|
+
*
|
|
16
|
+
* Robustness: an unparseable header (a `diff --git` line that doesn't match the
|
|
17
|
+
* expected `a/PATH b/PATH` shape) is skipped rather than fatal — we'd rather
|
|
18
|
+
* lose one entry than crash the overlay.
|
|
19
|
+
*/
|
|
20
|
+
export function parseUnifiedDiff(text) {
|
|
21
|
+
const files = [];
|
|
22
|
+
const contentByPath = {};
|
|
23
|
+
if (!text)
|
|
24
|
+
return { files, contentByPath };
|
|
25
|
+
const lines = text.split("\n");
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < lines.length) {
|
|
28
|
+
while (i < lines.length && !lines[i].startsWith("diff --git "))
|
|
29
|
+
i++;
|
|
30
|
+
if (i >= lines.length)
|
|
31
|
+
break;
|
|
32
|
+
const sectionStart = i;
|
|
33
|
+
const headerMatch = lines[i].match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
34
|
+
if (!headerMatch) {
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const oldPath = headerMatch[1];
|
|
39
|
+
const newPath = headerMatch[2];
|
|
40
|
+
i++;
|
|
41
|
+
let status = "M";
|
|
42
|
+
while (i < lines.length && !lines[i].startsWith("diff --git ")) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
if (line.startsWith("new file mode"))
|
|
45
|
+
status = "A";
|
|
46
|
+
else if (line.startsWith("deleted file mode"))
|
|
47
|
+
status = "D";
|
|
48
|
+
else if (line.startsWith("rename from") || line.startsWith("rename to"))
|
|
49
|
+
status = "R";
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
const path = status === "D" ? oldPath : newPath;
|
|
53
|
+
const file = { status, path };
|
|
54
|
+
if (status === "R" && oldPath !== newPath)
|
|
55
|
+
file.oldPath = oldPath;
|
|
56
|
+
files.push(file);
|
|
57
|
+
contentByPath[path] = lines.slice(sectionStart, i).join("\n");
|
|
58
|
+
}
|
|
59
|
+
return { files, contentByPath };
|
|
60
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -104,6 +104,16 @@ export declare function getSessionId(repoRoot: string, ticketId: string): string
|
|
|
104
104
|
* Store a session ID for a given ticket in .santree/metadata.json.
|
|
105
105
|
*/
|
|
106
106
|
export declare function setSessionId(repoRoot: string, ticketId: string, sessionId: string): void;
|
|
107
|
+
/**
|
|
108
|
+
* Remove the stored session_id for a ticket. Used when the underlying Claude
|
|
109
|
+
* session file is no longer on disk (Claude Code clears old transcripts) — the
|
|
110
|
+
* stored ID is unresumable, so we drop it to avoid showing a "Resume" action
|
|
111
|
+
* that will fail with "No conversation found with session ID".
|
|
112
|
+
*
|
|
113
|
+
* Leaves any other metadata fields (e.g. `base_branch`) intact and only drops
|
|
114
|
+
* the entry entirely when nothing remains.
|
|
115
|
+
*/
|
|
116
|
+
export declare function clearSessionId(repoRoot: string, ticketId: string): void;
|
|
107
117
|
/**
|
|
108
118
|
* Get the base branch for a given branch name.
|
|
109
119
|
* Looks up metadata first, falls back to the default branch.
|
|
@@ -151,6 +161,12 @@ export declare function getGitStatusAsync(cwd: string): Promise<string>;
|
|
|
151
161
|
* Returns empty string on failure.
|
|
152
162
|
*/
|
|
153
163
|
export declare function getStagedDiffStat(): string;
|
|
164
|
+
/**
|
|
165
|
+
* Get the full staged diff body for AI commit-message generation.
|
|
166
|
+
* Runs: `git diff --cached`. Uses a 5MB max buffer; very large diffs
|
|
167
|
+
* are truncated by the caller before sending to Claude.
|
|
168
|
+
*/
|
|
169
|
+
export declare function getStagedDiffContent(cwd?: string): string;
|
|
154
170
|
/**
|
|
155
171
|
* Count how many commits the current branch is behind origin/baseBranch.
|
|
156
172
|
* Runs: `git rev-list --count HEAD..origin/<baseBranch>`
|
|
@@ -168,6 +184,12 @@ export declare function getCommitsAhead(baseBranch: string): number;
|
|
|
168
184
|
* Returns 0 on failure.
|
|
169
185
|
*/
|
|
170
186
|
export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): Promise<number>;
|
|
187
|
+
/**
|
|
188
|
+
* Count how many commits HEAD is BEHIND origin/<baseBranch> in the given
|
|
189
|
+
* cwd. Used by the dashboard's main-repo row to show how stale the local
|
|
190
|
+
* checkout is. Returns 0 on failure (e.g. no upstream tracking branch).
|
|
191
|
+
*/
|
|
192
|
+
export declare function getCommitsBehindAsync(cwd: string, baseBranch: string): Promise<number>;
|
|
171
193
|
/**
|
|
172
194
|
* Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
|
|
173
195
|
* command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
|
package/dist/lib/git.js
CHANGED
|
@@ -293,6 +293,30 @@ export function setSessionId(repoRoot, ticketId, sessionId) {
|
|
|
293
293
|
all[ticketId] = { ...all[ticketId], session_id: sessionId };
|
|
294
294
|
writeAllMetadata(repoRoot, all);
|
|
295
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Remove the stored session_id for a ticket. Used when the underlying Claude
|
|
298
|
+
* session file is no longer on disk (Claude Code clears old transcripts) — the
|
|
299
|
+
* stored ID is unresumable, so we drop it to avoid showing a "Resume" action
|
|
300
|
+
* that will fail with "No conversation found with session ID".
|
|
301
|
+
*
|
|
302
|
+
* Leaves any other metadata fields (e.g. `base_branch`) intact and only drops
|
|
303
|
+
* the entry entirely when nothing remains.
|
|
304
|
+
*/
|
|
305
|
+
export function clearSessionId(repoRoot, ticketId) {
|
|
306
|
+
const all = readAllMetadata(repoRoot);
|
|
307
|
+
const entry = all[ticketId];
|
|
308
|
+
if (!entry || entry.session_id === undefined)
|
|
309
|
+
return;
|
|
310
|
+
const rest = { ...entry };
|
|
311
|
+
delete rest.session_id;
|
|
312
|
+
if (Object.keys(rest).length === 0) {
|
|
313
|
+
delete all[ticketId];
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
all[ticketId] = rest;
|
|
317
|
+
}
|
|
318
|
+
writeAllMetadata(repoRoot, all);
|
|
319
|
+
}
|
|
296
320
|
/**
|
|
297
321
|
* Get the base branch for a given branch name.
|
|
298
322
|
* Looks up metadata first, falls back to the default branch.
|
|
@@ -383,6 +407,14 @@ export async function getGitStatusAsync(cwd) {
|
|
|
383
407
|
export function getStagedDiffStat() {
|
|
384
408
|
return run("git diff --cached --stat") ?? "";
|
|
385
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Get the full staged diff body for AI commit-message generation.
|
|
412
|
+
* Runs: `git diff --cached`. Uses a 5MB max buffer; very large diffs
|
|
413
|
+
* are truncated by the caller before sending to Claude.
|
|
414
|
+
*/
|
|
415
|
+
export function getStagedDiffContent(cwd) {
|
|
416
|
+
return run("git diff --cached", { cwd, maxBuffer: 5 * 1024 * 1024 }) ?? "";
|
|
417
|
+
}
|
|
386
418
|
/**
|
|
387
419
|
* Count how many commits the current branch is behind origin/baseBranch.
|
|
388
420
|
* Runs: `git rev-list --count HEAD..origin/<baseBranch>`
|
|
@@ -409,6 +441,15 @@ export async function getCommitsAheadAsync(cwd, baseBranch) {
|
|
|
409
441
|
const output = await runAsync(`git -C "${cwd}" rev-list --count ${baseBranch}..HEAD`);
|
|
410
442
|
return output ? parseInt(output, 10) || 0 : 0;
|
|
411
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Count how many commits HEAD is BEHIND origin/<baseBranch> in the given
|
|
446
|
+
* cwd. Used by the dashboard's main-repo row to show how stale the local
|
|
447
|
+
* checkout is. Returns 0 on failure (e.g. no upstream tracking branch).
|
|
448
|
+
*/
|
|
449
|
+
export async function getCommitsBehindAsync(cwd, baseBranch) {
|
|
450
|
+
const output = await runAsync(`git -C "${cwd}" rev-list --count HEAD..origin/${baseBranch}`);
|
|
451
|
+
return output ? parseInt(output, 10) || 0 : 0;
|
|
452
|
+
}
|
|
412
453
|
/**
|
|
413
454
|
* Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
|
|
414
455
|
* command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
|
package/dist/lib/github.d.ts
CHANGED
|
@@ -133,6 +133,12 @@ export declare function getPRViewAsync(prNumber: number): Promise<PRViewDetail |
|
|
|
133
133
|
* Returns null if not in a GitHub repo or gh is unavailable.
|
|
134
134
|
*/
|
|
135
135
|
export declare function getRepoNameAsync(): Promise<string | null>;
|
|
136
|
+
/**
|
|
137
|
+
* Look up a GitHub user's display name (`name` field). Returns null if the
|
|
138
|
+
* user has no display name set, the API call fails, or `gh` isn't available.
|
|
139
|
+
* Caches per process — repeated calls for the same login are free.
|
|
140
|
+
*/
|
|
141
|
+
export declare function getGitHubUserNameAsync(login: string): Promise<string | null>;
|
|
136
142
|
/**
|
|
137
143
|
* Fetch open PRs where the current user's review is still pending (async).
|
|
138
144
|
* Uses the GitHub search API with `user-review-requested:@me` which excludes
|
package/dist/lib/github.js
CHANGED
|
@@ -256,6 +256,35 @@ export async function getRepoNameAsync() {
|
|
|
256
256
|
return null;
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Per-process cache of `login → display name`. GitHub display names rarely
|
|
261
|
+
* change inside a dashboard session, and `gh api users/<login>` is a network
|
|
262
|
+
* call we don't want to repeat per refresh. Resolves to `null` for users
|
|
263
|
+
* with no display name set (we'll fall back to login at the call site).
|
|
264
|
+
*/
|
|
265
|
+
const githubUserNameCache = new Map();
|
|
266
|
+
/**
|
|
267
|
+
* Look up a GitHub user's display name (`name` field). Returns null if the
|
|
268
|
+
* user has no display name set, the API call fails, or `gh` isn't available.
|
|
269
|
+
* Caches per process — repeated calls for the same login are free.
|
|
270
|
+
*/
|
|
271
|
+
export function getGitHubUserNameAsync(login) {
|
|
272
|
+
const cached = githubUserNameCache.get(login);
|
|
273
|
+
if (cached)
|
|
274
|
+
return cached;
|
|
275
|
+
const promise = (async () => {
|
|
276
|
+
try {
|
|
277
|
+
const { stdout } = await execAsync(`gh api users/${login} --jq .name`);
|
|
278
|
+
const name = stdout.trim();
|
|
279
|
+
return name && name !== "null" ? name : null;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
githubUserNameCache.set(login, promise);
|
|
286
|
+
return promise;
|
|
287
|
+
}
|
|
259
288
|
/**
|
|
260
289
|
* Fetch open PRs where the current user's review is still pending (async).
|
|
261
290
|
* Uses the GitHub search API with `user-review-requested:@me` which excludes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the platform's default browser.
|
|
3
|
+
* macOS → `open <url>`
|
|
4
|
+
* else → `xdg-open <url>`
|
|
5
|
+
*
|
|
6
|
+
* Returns true on apparent success, false on failure. Callers decide how to
|
|
7
|
+
* surface failures (e.g. a dashboard action message). Uses execSync with
|
|
8
|
+
* `stdio: "ignore"` — fast, no output leak into the dashboard's alt screen.
|
|
9
|
+
*/
|
|
10
|
+
export declare function openUrl(url: string): boolean;
|