santree 0.7.3 → 0.7.5
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 +76 -25
- package/dist/commands/doctor.js +12 -85
- package/dist/commands/setup.d.ts +11 -0
- package/dist/commands/setup.js +194 -0
- package/dist/commands/worktree/create.js +11 -10
- package/dist/commands/worktree/switch.js +3 -11
- package/dist/lib/cd-hint.d.ts +18 -0
- package/dist/lib/cd-hint.js +27 -0
- package/dist/lib/claude-config.d.ts +12 -0
- package/dist/lib/claude-config.js +86 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -1
- package/dist/lib/dashboard/DetailPanel.js +18 -1
- package/dist/lib/dashboard/Overlays.js +5 -0
- package/dist/lib/dashboard/types.d.ts +6 -0
- package/dist/lib/dashboard/types.js +37 -2
- package/dist/lib/setup/apply.d.ts +16 -0
- package/dist/lib/setup/apply.js +35 -0
- package/dist/lib/setup/gitignore.d.ts +17 -0
- package/dist/lib/setup/gitignore.js +57 -0
- package/dist/lib/setup/shell-config.d.ts +38 -0
- package/dist/lib/setup/shell-config.js +131 -0
- package/dist/lib/setup/steps.d.ts +35 -0
- package/dist/lib/setup/steps.js +328 -0
- package/dist/lib/setup/tools.d.ts +23 -0
- package/dist/lib/setup/tools.js +47 -0
- package/dist/lib/squirrel-loader.d.ts +3 -1
- package/dist/lib/squirrel-loader.js +2 -2
- package/dist/lib/triage-config.d.ts +21 -0
- package/dist/lib/triage-config.js +32 -0
- package/package.json +1 -2
- package/dist/commands/helpers/shell-init.d.ts +0 -11
- package/dist/commands/helpers/shell-init.js +0 -122
- package/shell/init.bash.njk +0 -129
- package/shell/init.zsh.njk +0 -204
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import * as fs from "fs";
|
|
7
7
|
import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../../lib/git.js";
|
|
8
8
|
import { spawnAsync } from "../../lib/exec.js";
|
|
9
|
+
import { formatCdCommand } from "../../lib/cd-hint.js";
|
|
9
10
|
import { getMultiplexer } from "../../lib/multiplexer/index.js";
|
|
10
11
|
export const description = "Create a new worktree from a branch";
|
|
11
12
|
export const options = z.object({
|
|
@@ -36,6 +37,7 @@ export default function Create({ options, args }) {
|
|
|
36
37
|
const [worktreePath, setWorktreePath] = useState("");
|
|
37
38
|
const [baseBranch, setBaseBranch] = useState(null);
|
|
38
39
|
const [muxWindowName, setMuxWindowName] = useState(null);
|
|
40
|
+
const [cdHint, setCdHint] = useState(null);
|
|
39
41
|
async function finalize(path, branch) {
|
|
40
42
|
const wantsWindow = options.window || options.tmux;
|
|
41
43
|
if (wantsWindow) {
|
|
@@ -43,7 +45,7 @@ export default function Create({ options, args }) {
|
|
|
43
45
|
if (!mux.isActive()) {
|
|
44
46
|
setMessage("Worktree created, but no active multiplexer");
|
|
45
47
|
setStatus("done");
|
|
46
|
-
|
|
48
|
+
setCdHint(formatCdCommand({ path }));
|
|
47
49
|
return;
|
|
48
50
|
}
|
|
49
51
|
setStatus("spawning-window");
|
|
@@ -52,28 +54,27 @@ export default function Create({ options, args }) {
|
|
|
52
54
|
setMuxWindowName(windowName);
|
|
53
55
|
let runCommand;
|
|
54
56
|
if (options.work) {
|
|
55
|
-
runCommand = options.plan ? "
|
|
57
|
+
runCommand = options.plan ? "santree worktree work --plan" : "santree worktree work";
|
|
56
58
|
}
|
|
57
59
|
const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
|
|
58
60
|
if (!result.ok) {
|
|
59
61
|
setMessage(`Worktree created, but failed to create window${result.message ? `: ${result.message}` : ""}`);
|
|
60
62
|
setStatus("done");
|
|
61
|
-
|
|
63
|
+
setCdHint(formatCdCommand({ path }));
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
64
66
|
setStatus("done");
|
|
65
67
|
const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
|
|
66
68
|
setMessage(`Worktree and window created!${workInfo}`);
|
|
67
|
-
//
|
|
69
|
+
// No cd hint when a window is created — the user is already in the new window
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
72
|
setStatus("done");
|
|
71
73
|
setMessage("Worktree created successfully!");
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
74
|
+
setCdHint(formatCdCommand({
|
|
75
|
+
path,
|
|
76
|
+
work: options.work ? { mode: options.plan ? "plan" : "implement" } : undefined,
|
|
77
|
+
}));
|
|
77
78
|
}
|
|
78
79
|
useEffect(() => {
|
|
79
80
|
async function run() {
|
|
@@ -159,5 +160,5 @@ export default function Create({ options, args }) {
|
|
|
159
160
|
status === "creating" ||
|
|
160
161
|
status === "init-script" ||
|
|
161
162
|
status === "spawning-window";
|
|
162
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && _jsxs(Text, { dimColor: true, children: [" window: ", muxWindowName] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
163
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && _jsxs(Text, { dimColor: true, children: [" window: ", muxWindowName] }), cdHint && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "\u2192 Run this to enter the worktree:" }), _jsxs(Text, { color: "cyan", children: [" ", cdHint] })] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
163
164
|
}
|
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
2
|
import { Text, Box } from "ink";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { getWorktreePath } from "../../lib/git.js";
|
|
5
|
+
import { formatCdCommand } from "../../lib/cd-hint.js";
|
|
6
6
|
export const description = "Switch to another worktree";
|
|
7
7
|
export const args = z.tuple([z.string().describe("Branch name to switch to")]);
|
|
8
8
|
export default function Switch({ args }) {
|
|
9
9
|
const [branchName] = args;
|
|
10
|
-
const hasOutputRef = useRef(false);
|
|
11
10
|
// Find worktree path synchronously
|
|
12
11
|
const worktreePath = getWorktreePath(branchName);
|
|
13
|
-
// Output SANTREE_CD once (before Ink fully renders)
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
if (worktreePath && !hasOutputRef.current) {
|
|
16
|
-
hasOutputRef.current = true;
|
|
17
|
-
process.stdout.write(`SANTREE_CD:${worktreePath}\n`);
|
|
18
|
-
}
|
|
19
|
-
}, [worktreePath]);
|
|
20
12
|
const status = worktreePath ? "done" : "error";
|
|
21
13
|
const error = worktreePath ? null : `Worktree not found for branch: ${branchName}`;
|
|
22
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD00 Switch" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "green", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: worktreePath })] }))] }), _jsxs(Box, { marginTop: 1, children: [status === "done" && (
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD00 Switch" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "green", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: worktreePath })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [status === "done" && worktreePath && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "\u2192 Run this to enter the worktree:" }), _jsxs(Text, { color: "cyan", children: [" ", formatCdCommand({ path: worktreePath })] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
|
|
23
15
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* santree is a plain binary — a child process can't change its parent shell's
|
|
3
|
+
* working directory. So when a command wants you to land in a worktree, it
|
|
4
|
+
* prints the command for you to run. (Dashboard flows with tmux/cmux open a new
|
|
5
|
+
* window in the right directory instead and never need this.)
|
|
6
|
+
*/
|
|
7
|
+
export interface CdHint {
|
|
8
|
+
path: string;
|
|
9
|
+
/** Append a `worktree work` launch after the cd. */
|
|
10
|
+
work?: {
|
|
11
|
+
mode: "plan" | "implement" | string;
|
|
12
|
+
contextFile?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The shell command to enter a worktree (and optionally start work). */
|
|
16
|
+
export declare function formatCdCommand(hint: CdHint): string;
|
|
17
|
+
/** Print the cd hint to stdout (for non-Ink contexts, e.g. after the dashboard exits). */
|
|
18
|
+
export declare function printCdHint(hint: CdHint): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* santree is a plain binary — a child process can't change its parent shell's
|
|
3
|
+
* working directory. So when a command wants you to land in a worktree, it
|
|
4
|
+
* prints the command for you to run. (Dashboard flows with tmux/cmux open a new
|
|
5
|
+
* window in the right directory instead and never need this.)
|
|
6
|
+
*/
|
|
7
|
+
/** Single-quote a value for safe copy-paste into a POSIX shell. */
|
|
8
|
+
function sq(s) {
|
|
9
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
10
|
+
}
|
|
11
|
+
/** The shell command to enter a worktree (and optionally start work). */
|
|
12
|
+
export function formatCdCommand(hint) {
|
|
13
|
+
let cmd = `cd ${sq(hint.path)}`;
|
|
14
|
+
if (hint.work) {
|
|
15
|
+
let work = "santree worktree work";
|
|
16
|
+
if (hint.work.mode === "plan")
|
|
17
|
+
work += " --plan";
|
|
18
|
+
if (hint.work.contextFile)
|
|
19
|
+
work += ` --context-file ${sq(hint.work.contextFile)}`;
|
|
20
|
+
cmd += ` && ${work}`;
|
|
21
|
+
}
|
|
22
|
+
return cmd;
|
|
23
|
+
}
|
|
24
|
+
/** Print the cd hint to stdout (for non-Ink contexts, e.g. after the dashboard exits). */
|
|
25
|
+
export function printCdHint(hint) {
|
|
26
|
+
console.log(`\n→ Run this to enter the worktree:\n ${formatCdCommand(hint)}`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function claudeSettingsPath(): string;
|
|
2
|
+
export declare function claudeConfigPath(): string;
|
|
3
|
+
export declare function readJsonSafe(filePath: string): Record<string, any>;
|
|
4
|
+
export declare function getStatuslineCommand(): string | undefined;
|
|
5
|
+
export declare function isStatuslineConfigured(): boolean;
|
|
6
|
+
export declare function configureStatusline(): string;
|
|
7
|
+
export declare function isRemoteControlEnabled(): boolean;
|
|
8
|
+
export declare function enableRemoteControl(): string;
|
|
9
|
+
export declare const SESSION_SIGNAL_EVENTS: string[];
|
|
10
|
+
/** Returns the session-signal events that are NOT yet wired in settings.json. */
|
|
11
|
+
export declare function missingSessionSignalHooks(): string[];
|
|
12
|
+
export declare function isSessionSignalConfigured(): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Shared read/detect/configure helpers for the two Claude Code config files
|
|
5
|
+
* santree touches:
|
|
6
|
+
* - ~/.claude/settings.json (statusline + hooks)
|
|
7
|
+
* - ~/.claude.json (remote control)
|
|
8
|
+
*
|
|
9
|
+
* Both `santree doctor` (detect) and `santree setup` (configure) go through
|
|
10
|
+
* here so the two commands can never disagree about what "configured" means.
|
|
11
|
+
*/
|
|
12
|
+
const STATUSLINE_COMMAND = "santree helpers statusline";
|
|
13
|
+
export function claudeSettingsPath() {
|
|
14
|
+
return path.join(process.env.HOME || "", ".claude", "settings.json");
|
|
15
|
+
}
|
|
16
|
+
export function claudeConfigPath() {
|
|
17
|
+
return path.join(process.env.HOME || "", ".claude.json");
|
|
18
|
+
}
|
|
19
|
+
export function readJsonSafe(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(filePath)) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Invalid/unreadable — caller decides whether to start fresh.
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
function writeJson(filePath, data) {
|
|
31
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
|
|
33
|
+
}
|
|
34
|
+
// ── Statusline ───────────────────────────────────────────────────────────────
|
|
35
|
+
export function getStatuslineCommand() {
|
|
36
|
+
const settings = readJsonSafe(claudeSettingsPath());
|
|
37
|
+
const cmd = settings.statusLine?.command;
|
|
38
|
+
return cmd ? String(cmd) : undefined;
|
|
39
|
+
}
|
|
40
|
+
export function isStatuslineConfigured() {
|
|
41
|
+
const cmd = getStatuslineCommand();
|
|
42
|
+
return (!!cmd && (cmd.includes("santree statusline") || cmd.includes("santree helpers statusline")));
|
|
43
|
+
}
|
|
44
|
+
export function configureStatusline() {
|
|
45
|
+
const settingsPath = claudeSettingsPath();
|
|
46
|
+
const settings = readJsonSafe(settingsPath);
|
|
47
|
+
settings.statusLine = { type: "command", command: STATUSLINE_COMMAND };
|
|
48
|
+
writeJson(settingsPath, settings);
|
|
49
|
+
return settingsPath;
|
|
50
|
+
}
|
|
51
|
+
// ── Remote control ────────────────────────────────────────────────────────────
|
|
52
|
+
export function isRemoteControlEnabled() {
|
|
53
|
+
return readJsonSafe(claudeConfigPath()).remoteControlAtStartup === true;
|
|
54
|
+
}
|
|
55
|
+
export function enableRemoteControl() {
|
|
56
|
+
const configPath = claudeConfigPath();
|
|
57
|
+
const config = readJsonSafe(configPath);
|
|
58
|
+
config.remoteControlAtStartup = true;
|
|
59
|
+
writeJson(configPath, config);
|
|
60
|
+
return configPath;
|
|
61
|
+
}
|
|
62
|
+
// ── Session-signal hooks ──────────────────────────────────────────────────────
|
|
63
|
+
export const SESSION_SIGNAL_EVENTS = ["Notification", "Stop", "UserPromptSubmit", "SessionEnd"];
|
|
64
|
+
/** Returns the session-signal events that are NOT yet wired in settings.json. */
|
|
65
|
+
export function missingSessionSignalHooks() {
|
|
66
|
+
const settings = readJsonSafe(claudeSettingsPath());
|
|
67
|
+
const hooks = settings.hooks || {};
|
|
68
|
+
const missing = [];
|
|
69
|
+
for (const event of SESSION_SIGNAL_EVENTS) {
|
|
70
|
+
const eventHooks = hooks[event];
|
|
71
|
+
if (!Array.isArray(eventHooks)) {
|
|
72
|
+
missing.push(event);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const found = eventHooks.some((entry) => {
|
|
76
|
+
const inner = entry.hooks || [];
|
|
77
|
+
return inner.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
|
|
78
|
+
});
|
|
79
|
+
if (!found)
|
|
80
|
+
missing.push(event);
|
|
81
|
+
}
|
|
82
|
+
return missing;
|
|
83
|
+
}
|
|
84
|
+
export function isSessionSignalConfigured() {
|
|
85
|
+
return missingSessionSignalHooks().length === 0;
|
|
86
|
+
}
|
|
@@ -8,6 +8,9 @@ interface Props {
|
|
|
8
8
|
creationLogs: string;
|
|
9
9
|
/** Deletion progress for the selected worktree, when one is being removed. */
|
|
10
10
|
deleteStatus?: DeleteStatus;
|
|
11
|
+
/** True while a just-created PR is shown optimistically, before a refresh
|
|
12
|
+
* confirms it (renders a "syncing…" hint next to the PR). */
|
|
13
|
+
prSyncing?: boolean;
|
|
11
14
|
/** Triage mode: hide worktree/PR/checks sections (they never apply to an
|
|
12
15
|
* inbox issue) and show the discussion instead. */
|
|
13
16
|
triage?: boolean;
|
|
@@ -37,6 +40,7 @@ export type IssueActionItem = {
|
|
|
37
40
|
export declare function buildIssueActions(di: DashboardIssue, trackerName: string, opts?: {
|
|
38
41
|
tab?: DashboardTab;
|
|
39
42
|
canMutate?: boolean;
|
|
43
|
+
triageInvestigateConfigured?: boolean;
|
|
40
44
|
}): IssueActionItem[];
|
|
41
|
-
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
45
|
+
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
42
46
|
export {};
|
|
@@ -85,6 +85,13 @@ export function buildIssueActions(di, trackerName, opts) {
|
|
|
85
85
|
if (opts?.tab === "triage") {
|
|
86
86
|
items.push({ key: "w", label: "Send to tree", color: "cyan" });
|
|
87
87
|
items.push({ key: "a", label: "Ask", color: "cyan" });
|
|
88
|
+
// Greyed until `_triage.skill_name`/`_triage.prompt` is set in
|
|
89
|
+
// `.santree/metadata.json` — pressing it then just prints a hint.
|
|
90
|
+
items.push({
|
|
91
|
+
key: "i",
|
|
92
|
+
label: "Investigate",
|
|
93
|
+
color: opts.triageInvestigateConfigured ? "cyan" : "gray",
|
|
94
|
+
});
|
|
88
95
|
items.push({ key: "s", label: "Schedule", color: "cyan" });
|
|
89
96
|
if (issue.url) {
|
|
90
97
|
items.push({ key: "o", label: trackerName, color: "gray" });
|
|
@@ -151,7 +158,7 @@ function sectionHeader(icon, label, iconColor = "cyan") {
|
|
|
151
158
|
],
|
|
152
159
|
};
|
|
153
160
|
}
|
|
154
|
-
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage = false, comments, onCall, }) {
|
|
161
|
+
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing = false, triage = false, comments, onCall, }) {
|
|
155
162
|
// Show deletion progress when the selected worktree is being removed.
|
|
156
163
|
if (issue && deleteStatus) {
|
|
157
164
|
const logLines = deleteStatus.logs.split("\n");
|
|
@@ -550,11 +557,21 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
|
|
|
550
557
|
{ text: " " },
|
|
551
558
|
{ text: pr.state, color: prColor },
|
|
552
559
|
{ text: draft, dim: true },
|
|
560
|
+
...(prSyncing ? [{ text: " · syncing…", color: "yellow" }] : []),
|
|
553
561
|
],
|
|
554
562
|
});
|
|
555
563
|
if (pr.url) {
|
|
556
564
|
lines.push({ text: ` ${pr.url}`, dim: true });
|
|
557
565
|
}
|
|
566
|
+
if (prSyncing) {
|
|
567
|
+
// Just created from the dashboard — number/url are known, but checks
|
|
568
|
+
// and reviews aren't fetched yet. Show a clear loading line for this
|
|
569
|
+
// one PR (other PRs keep their already-loaded data).
|
|
570
|
+
lines.push({
|
|
571
|
+
text: "",
|
|
572
|
+
segments: [{ text: " ⟳ loading checks & reviews…", color: "yellow" }],
|
|
573
|
+
});
|
|
574
|
+
}
|
|
558
575
|
}
|
|
559
576
|
else if (!isMain) {
|
|
560
577
|
lines.push(sectionHeader("◉", "Pull Request"));
|
|
@@ -45,6 +45,11 @@ const LEGEND = [
|
|
|
45
45
|
{ glyph: "d", color: "red", meaning: "Delete issue (built-in) / remove worktree (Trees)" },
|
|
46
46
|
{ glyph: "w", color: "cyan", meaning: "Start work / send to tree (creates a worktree)" },
|
|
47
47
|
{ glyph: "a", color: "cyan", meaning: "Ask Claude about the issue + comments (Triage tab)" },
|
|
48
|
+
{
|
|
49
|
+
glyph: "i",
|
|
50
|
+
color: "cyan",
|
|
51
|
+
meaning: "Investigate ticket via your configured skill/prompt (Triage tab)",
|
|
52
|
+
},
|
|
48
53
|
{ glyph: "s", color: "cyan", meaning: "Triage on-call schedule (Triage tab)" },
|
|
49
54
|
],
|
|
50
55
|
},
|
|
@@ -163,6 +163,12 @@ export interface DashboardState {
|
|
|
163
163
|
creationError: string | null;
|
|
164
164
|
/** In-flight (and just-finished) worktree deletions, keyed by ticket id. */
|
|
165
165
|
deletingTickets: Record<string, DeleteStatus>;
|
|
166
|
+
/**
|
|
167
|
+
* Optimistic PRs just created from the dashboard, keyed by ticket id. They
|
|
168
|
+
* make the "Open PR" action appear immediately (before the next refresh
|
|
169
|
+
* surfaces the real PR), and are reconciled away by SET_DATA.
|
|
170
|
+
*/
|
|
171
|
+
pendingPrs: Record<string, PRInfo>;
|
|
166
172
|
commitPhase: CommitPhase;
|
|
167
173
|
commitMessage: string;
|
|
168
174
|
commitError: string | null;
|
|
@@ -48,6 +48,7 @@ export const initialState = {
|
|
|
48
48
|
creationLogs: "",
|
|
49
49
|
creationError: null,
|
|
50
50
|
deletingTickets: {},
|
|
51
|
+
pendingPrs: {},
|
|
51
52
|
commitPhase: "idle",
|
|
52
53
|
commitMessage: "",
|
|
53
54
|
commitError: null,
|
|
@@ -117,12 +118,27 @@ export function reducer(state, action) {
|
|
|
117
118
|
// keep their row, so their entries survive and stay visible.
|
|
118
119
|
const presentTreeIds = new Set(action.flatTrees.map((d) => d.issue.identifier));
|
|
119
120
|
const deletingTickets = Object.fromEntries(Object.entries(state.deletingTickets).filter(([id]) => presentTreeIds.has(id)));
|
|
121
|
+
// Reconcile optimistic PRs: keep the placeholder until the refresh
|
|
122
|
+
// actually surfaces the PR (GitHub may not have indexed it yet), then
|
|
123
|
+
// drop it once real PR data — or the worktree itself — is gone.
|
|
124
|
+
let reconciledTrees = action.flatTrees;
|
|
125
|
+
const pendingPrs = {};
|
|
126
|
+
for (const [id, optimistic] of Object.entries(state.pendingPrs)) {
|
|
127
|
+
const idx = reconciledTrees.findIndex((d) => d.issue.identifier === id);
|
|
128
|
+
if (idx < 0)
|
|
129
|
+
continue; // worktree gone → drop
|
|
130
|
+
if (reconciledTrees[idx].pr)
|
|
131
|
+
continue; // real PR arrived → reconciled
|
|
132
|
+
reconciledTrees = reconciledTrees.map((d, i) => (i === idx ? { ...d, pr: optimistic } : d));
|
|
133
|
+
pendingPrs[id] = optimistic; // still pending → keep showing it
|
|
134
|
+
}
|
|
120
135
|
return {
|
|
121
136
|
...state,
|
|
122
137
|
groups: action.groups,
|
|
123
138
|
flatIssues: action.flatIssues,
|
|
124
139
|
treeGroups: action.treeGroups,
|
|
125
|
-
flatTrees:
|
|
140
|
+
flatTrees: reconciledTrees,
|
|
141
|
+
pendingPrs,
|
|
126
142
|
triageGroups: action.triageGroups,
|
|
127
143
|
flatTriage: action.flatTriage,
|
|
128
144
|
deletingTickets,
|
|
@@ -408,14 +424,33 @@ export function reducer(state, action) {
|
|
|
408
424
|
return { ...state, prCreateDraft: !state.prCreateDraft };
|
|
409
425
|
case "PR_CREATE_EDIT":
|
|
410
426
|
return { ...state, prCreatePhase: "review" };
|
|
411
|
-
case "PR_CREATE_DONE":
|
|
427
|
+
case "PR_CREATE_DONE": {
|
|
428
|
+
// Optimistically attach the new PR to its tree row so the "Open PR"
|
|
429
|
+
// action appears immediately, instead of waiting for the next refresh
|
|
430
|
+
// to surface it via `gh pr view`.
|
|
431
|
+
const ticketId = state.prCreateTicketId;
|
|
432
|
+
let flatTrees = state.flatTrees;
|
|
433
|
+
let pendingPrs = state.pendingPrs;
|
|
434
|
+
if (ticketId) {
|
|
435
|
+
const optimistic = {
|
|
436
|
+
number: action.url.match(/\/pull\/(\d+)/)?.[1] ?? "?",
|
|
437
|
+
state: "OPEN",
|
|
438
|
+
isDraft: state.prCreateDraft,
|
|
439
|
+
url: action.url,
|
|
440
|
+
};
|
|
441
|
+
flatTrees = state.flatTrees.map((d) => d.issue.identifier === ticketId && !d.pr ? { ...d, pr: optimistic } : d);
|
|
442
|
+
pendingPrs = { ...state.pendingPrs, [ticketId]: optimistic };
|
|
443
|
+
}
|
|
412
444
|
return {
|
|
413
445
|
...state,
|
|
414
446
|
prCreatePhase: "done",
|
|
415
447
|
prCreateUrl: action.url,
|
|
416
448
|
prCreateBody: null,
|
|
417
449
|
prCreateTitle: null,
|
|
450
|
+
flatTrees,
|
|
451
|
+
pendingPrs,
|
|
418
452
|
};
|
|
453
|
+
}
|
|
419
454
|
case "PR_CREATE_CANCEL":
|
|
420
455
|
return {
|
|
421
456
|
...state,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTY hand-off for setup steps that run an interactive subprocess (brew install,
|
|
3
|
+
* gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
|
|
4
|
+
* drop raw mode so the child owns the terminal, run it synchronously (Ink can't
|
|
5
|
+
* repaint while the event loop is blocked), then restore raw mode.
|
|
6
|
+
*
|
|
7
|
+
* Returns the child's exit code (1 on spawn failure).
|
|
8
|
+
*/
|
|
9
|
+
export declare function spawnTTY(cmd: string, args: string[], opts?: {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}): number;
|
|
12
|
+
/** argv to re-invoke this same santree binary (robust to global vs local installs). */
|
|
13
|
+
export declare function santreeSelfArgv(args: string[]): {
|
|
14
|
+
cmd: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
/**
|
|
3
|
+
* TTY hand-off for setup steps that run an interactive subprocess (brew install,
|
|
4
|
+
* gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
|
|
5
|
+
* drop raw mode so the child owns the terminal, run it synchronously (Ink can't
|
|
6
|
+
* repaint while the event loop is blocked), then restore raw mode.
|
|
7
|
+
*
|
|
8
|
+
* Returns the child's exit code (1 on spawn failure).
|
|
9
|
+
*/
|
|
10
|
+
export function spawnTTY(cmd, args, opts) {
|
|
11
|
+
const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
|
|
12
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
13
|
+
try {
|
|
14
|
+
process.stdin.setRawMode(false);
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
}
|
|
18
|
+
const result = spawnSync(cmd, args, { stdio: "inherit", cwd: opts?.cwd });
|
|
19
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
20
|
+
try {
|
|
21
|
+
process.stdin.setRawMode(wasRaw);
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
if (result.error)
|
|
26
|
+
return 1;
|
|
27
|
+
return result.status ?? 1;
|
|
28
|
+
}
|
|
29
|
+
/** argv to re-invoke this same santree binary (robust to global vs local installs). */
|
|
30
|
+
export function santreeSelfArgv(args) {
|
|
31
|
+
const entry = process.argv[1];
|
|
32
|
+
if (entry)
|
|
33
|
+
return { cmd: process.execPath, args: [entry, ...args] };
|
|
34
|
+
return { cmd: "santree", args };
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ignore-rule management for santree's per-repo files.
|
|
3
|
+
*
|
|
4
|
+
* We add SPECIFIC entries (not a blanket `.santree/`) so `.santree/issues/`
|
|
5
|
+
* stays version-controlled for the Local tracker. Trailing slashes mark
|
|
6
|
+
* directories (see santree's own .gitignore convention).
|
|
7
|
+
*/
|
|
8
|
+
export declare const SANTREE_IGNORE_ENTRIES: string[];
|
|
9
|
+
export type IgnoreTarget = "gitignore" | "exclude";
|
|
10
|
+
export declare function ignoreTargetPath(repoRoot: string, target: IgnoreTarget): string;
|
|
11
|
+
/** Entries that are not yet ignored by git (so we don't write duplicates). */
|
|
12
|
+
export declare function missingIgnoreEntries(repoRoot: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Append the still-missing santree entries to the chosen target file. Returns
|
|
15
|
+
* the entries actually written (skips any already present as literal lines).
|
|
16
|
+
*/
|
|
17
|
+
export declare function addIgnoreEntries(repoRoot: string, target: IgnoreTarget): string[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { run } from "../exec.js";
|
|
5
|
+
/**
|
|
6
|
+
* Ignore-rule management for santree's per-repo files.
|
|
7
|
+
*
|
|
8
|
+
* We add SPECIFIC entries (not a blanket `.santree/`) so `.santree/issues/`
|
|
9
|
+
* stays version-controlled for the Local tracker. Trailing slashes mark
|
|
10
|
+
* directories (see santree's own .gitignore convention).
|
|
11
|
+
*/
|
|
12
|
+
export const SANTREE_IGNORE_ENTRIES = [
|
|
13
|
+
".santree/worktrees/",
|
|
14
|
+
".santree/metadata.json",
|
|
15
|
+
".santree/session-states/",
|
|
16
|
+
];
|
|
17
|
+
export function ignoreTargetPath(repoRoot, target) {
|
|
18
|
+
if (target === "exclude") {
|
|
19
|
+
// Resolve the real excludes file (handles non-standard git dirs).
|
|
20
|
+
const rel = run("git rev-parse --git-path info/exclude", { cwd: repoRoot });
|
|
21
|
+
return rel ? path.resolve(repoRoot, rel) : path.join(repoRoot, ".git", "info", "exclude");
|
|
22
|
+
}
|
|
23
|
+
return path.join(repoRoot, ".gitignore");
|
|
24
|
+
}
|
|
25
|
+
function isGitIgnored(relPath, repoRoot) {
|
|
26
|
+
try {
|
|
27
|
+
execSync(`git check-ignore -q "${relPath}"`, { cwd: repoRoot, stdio: "ignore" });
|
|
28
|
+
return true; // exit 0 = ignored
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Entries that are not yet ignored by git (so we don't write duplicates). */
|
|
35
|
+
export function missingIgnoreEntries(repoRoot) {
|
|
36
|
+
return SANTREE_IGNORE_ENTRIES.filter((e) => !isGitIgnored(e, repoRoot));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Append the still-missing santree entries to the chosen target file. Returns
|
|
40
|
+
* the entries actually written (skips any already present as literal lines).
|
|
41
|
+
*/
|
|
42
|
+
export function addIgnoreEntries(repoRoot, target) {
|
|
43
|
+
const filePath = ignoreTargetPath(repoRoot, target);
|
|
44
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
|
|
45
|
+
const existingLines = new Set(existing
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((l) => l.trim())
|
|
48
|
+
.filter(Boolean));
|
|
49
|
+
const toAdd = missingIgnoreEntries(repoRoot).filter((e) => !existingLines.has(e));
|
|
50
|
+
if (toAdd.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
53
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
54
|
+
const header = existing.includes("# santree") ? "" : "\n# santree\n";
|
|
55
|
+
fs.appendFileSync(filePath, prefix + header + toAdd.join("\n") + "\n");
|
|
56
|
+
return toAdd;
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-config detection + idempotent editing for `santree setup`.
|
|
3
|
+
*
|
|
4
|
+
* Everything santree writes to a user's shell rc lives inside a single managed
|
|
5
|
+
* block (nvm/conda style) so re-running setup replaces the block rather than
|
|
6
|
+
* appending duplicates. Individual lines inside the block are tagged with a
|
|
7
|
+
* trailing `# santree:<key>` comment so each setup step can upsert just its own
|
|
8
|
+
* line without disturbing the others.
|
|
9
|
+
*/
|
|
10
|
+
export type ShellName = "zsh" | "bash" | "unknown";
|
|
11
|
+
export interface ShellConfig {
|
|
12
|
+
shell: ShellName;
|
|
13
|
+
/** rc file the managed block is written to (interactive shell init). */
|
|
14
|
+
rcPath: string;
|
|
15
|
+
/** Files scanned to detect pre-existing `export FOO=...` lines (rc + env files). */
|
|
16
|
+
scanPaths: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the rc file the managed block should live in, honoring ZDOTDIR (zsh)
|
|
20
|
+
* so users with an XDG-based setup (~/.config/zsh, no ~/.zshrc) get the block in
|
|
21
|
+
* the right place.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveShellConfig(shellEnv?: string): ShellConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Upsert a single tagged line into the managed block, creating the block (and
|
|
26
|
+
* the rc file) if needed. The line for `key` is replaced if present, else
|
|
27
|
+
* appended. `line` is the raw shell statement WITHOUT the trailing tag comment.
|
|
28
|
+
*/
|
|
29
|
+
export declare function upsertManagedLine(rcPath: string, key: string, line: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Detect whether `name` is already exported by the user's shell config (any of
|
|
32
|
+
* `scanPaths`) or the current environment. Used so setup doesn't clobber an
|
|
33
|
+
* export the user already maintains by hand (e.g. SANTREE_EDITOR in .zshenv).
|
|
34
|
+
* Ignores santree's own managed block so a prior setup run isn't mistaken for a
|
|
35
|
+
* hand-maintained export.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isEnvVarSet(name: string, cfg: ShellConfig): boolean;
|
|
38
|
+
export declare function envKey(name: string): string;
|