@ohzw/worktree-command-tui 0.1.1 → 0.1.2
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 +5 -0
- package/dist/app.d.ts +1 -1
- package/dist/app.js +235 -113
- package/dist/components/ActionPanel.d.ts +4 -2
- package/dist/components/ActionPanel.js +87 -135
- package/dist/components/ContextBar.d.ts +4 -1
- package/dist/components/ContextBar.js +27 -3
- package/dist/components/HelpWindow.d.ts +7 -0
- package/dist/components/HelpWindow.js +29 -0
- package/dist/components/LogPanel.d.ts +10 -3
- package/dist/components/LogPanel.js +239 -33
- package/dist/components/WorktreeList.js +20 -40
- package/dist/core/command-runner.d.ts +11 -0
- package/dist/core/command-runner.js +44 -0
- package/dist/core/config-lifecycle.d.ts +25 -0
- package/dist/core/config-lifecycle.js +143 -0
- package/dist/core/config.d.ts +2 -3
- package/dist/core/config.js +0 -48
- package/dist/core/git-metadata.d.ts +25 -0
- package/dist/core/git-metadata.js +84 -0
- package/dist/core/git-worktrees.d.ts +2 -1
- package/dist/core/git-worktrees.js +30 -11
- package/dist/core/github-metadata.d.ts +14 -0
- package/dist/core/github-metadata.js +137 -0
- package/dist/core/init.d.ts +3 -2
- package/dist/core/init.js +9 -57
- package/dist/core/log-reader.d.ts +7 -0
- package/dist/core/log-reader.js +43 -0
- package/dist/core/runtime-state.d.ts +42 -0
- package/dist/core/runtime-state.js +125 -0
- package/dist/core/runtime.d.ts +19 -39
- package/dist/core/runtime.js +112 -216
- package/dist/core/tui-interaction.d.ts +31 -0
- package/dist/core/tui-interaction.js +59 -0
- package/dist/core/worktree-projection.d.ts +76 -0
- package/dist/core/worktree-projection.js +124 -0
- package/dist/terminal/viewport.d.ts +15 -0
- package/dist/terminal/viewport.js +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function toLogFileBase(branch) {
|
|
3
|
+
return branch.replace(/[\\/]/g, '-');
|
|
4
|
+
}
|
|
5
|
+
export function createRuntimeStateActions({ config, paths, adapter }) {
|
|
6
|
+
const refreshLogs = async () => {
|
|
7
|
+
const active = await adapter.readActive();
|
|
8
|
+
return {
|
|
9
|
+
logs: await adapter.readLogs(active?.logPath ?? null),
|
|
10
|
+
activePath: active?.worktreePath ?? null,
|
|
11
|
+
activeBranch: active?.branch ?? null,
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
const refreshWithStatus = async (run, preserveRunningStatus) => {
|
|
15
|
+
const status = await run();
|
|
16
|
+
const model = await adapter.refresh();
|
|
17
|
+
if (preserveRunningStatus && model.activePath !== null && status.kind === 'idle') {
|
|
18
|
+
return {
|
|
19
|
+
...model,
|
|
20
|
+
status: { kind: 'running', message: status.message },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
...model,
|
|
25
|
+
status,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const stop = async () => {
|
|
29
|
+
const active = await adapter.readActive();
|
|
30
|
+
if (active) {
|
|
31
|
+
await adapter.stopSession(active);
|
|
32
|
+
await adapter.clearSession();
|
|
33
|
+
}
|
|
34
|
+
const model = await adapter.refresh();
|
|
35
|
+
return {
|
|
36
|
+
...model,
|
|
37
|
+
activePath: null,
|
|
38
|
+
activeBranch: null,
|
|
39
|
+
status: { kind: 'idle', message: active ? 'stopped' : 'already stopped' },
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const setup = async (worktreePath) => {
|
|
43
|
+
const setupCommand = config.setupCommand;
|
|
44
|
+
if (setupCommand === undefined) {
|
|
45
|
+
const model = await adapter.refresh();
|
|
46
|
+
return {
|
|
47
|
+
...model,
|
|
48
|
+
status: { kind: 'idle', message: 'setup command is not configured' },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const branch = await adapter.readWorktreeBranch(worktreePath);
|
|
52
|
+
const logFileBase = `${toLogFileBase(branch)}.setup`;
|
|
53
|
+
const setupLogPath = path.join(paths.logsDir, `${logFileBase}.log`);
|
|
54
|
+
try {
|
|
55
|
+
await adapter.runSetup({
|
|
56
|
+
command: setupCommand,
|
|
57
|
+
cwd: worktreePath,
|
|
58
|
+
logsDir: paths.logsDir,
|
|
59
|
+
logFileBase,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const model = await adapter.refresh();
|
|
64
|
+
return {
|
|
65
|
+
...model,
|
|
66
|
+
status: { kind: 'error', message: error instanceof Error ? error.message : String(error) },
|
|
67
|
+
logs: await adapter.readLogs(setupLogPath),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const model = await adapter.refresh();
|
|
71
|
+
return {
|
|
72
|
+
...model,
|
|
73
|
+
status: { kind: model.activePath === null ? 'idle' : 'running', message: `setup complete for ${branch}` },
|
|
74
|
+
logs: await adapter.readLogs(setupLogPath),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const start = async (worktreePath) => {
|
|
78
|
+
const current = await adapter.readActive();
|
|
79
|
+
if (current?.worktreePath === worktreePath) {
|
|
80
|
+
const model = await adapter.refresh();
|
|
81
|
+
return {
|
|
82
|
+
...model,
|
|
83
|
+
activePath: current.worktreePath,
|
|
84
|
+
activeBranch: current.branch,
|
|
85
|
+
status: { kind: 'idle', message: 'already active' },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const invalidReason = await adapter.getInvalidReason(worktreePath);
|
|
89
|
+
if (invalidReason) {
|
|
90
|
+
throw new Error(invalidReason);
|
|
91
|
+
}
|
|
92
|
+
if (current) {
|
|
93
|
+
await adapter.stopSession(current);
|
|
94
|
+
await adapter.clearSession();
|
|
95
|
+
}
|
|
96
|
+
const branch = await adapter.readWorktreeBranch(worktreePath);
|
|
97
|
+
const started = await adapter.startCommand({
|
|
98
|
+
command: config.command,
|
|
99
|
+
cwd: worktreePath,
|
|
100
|
+
logsDir: paths.logsDir,
|
|
101
|
+
logFileBase: toLogFileBase(branch),
|
|
102
|
+
});
|
|
103
|
+
await adapter.writeSession({
|
|
104
|
+
namespace: config.namespace,
|
|
105
|
+
worktreePath,
|
|
106
|
+
branch,
|
|
107
|
+
pid: started.pid,
|
|
108
|
+
pgid: started.pgid,
|
|
109
|
+
port: config.port,
|
|
110
|
+
logPath: started.logPath,
|
|
111
|
+
startedAt: adapter.nowIso(),
|
|
112
|
+
});
|
|
113
|
+
const model = await adapter.refresh();
|
|
114
|
+
return {
|
|
115
|
+
...model,
|
|
116
|
+
activePath: worktreePath,
|
|
117
|
+
activeBranch: branch,
|
|
118
|
+
status: { kind: 'running', message: `started ${branch}` },
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
const openEditor = async (worktreePath) => refreshWithStatus(() => adapter.openEditor(worktreePath), true);
|
|
122
|
+
const openPullRequest = async (worktreePath) => refreshWithStatus(() => adapter.openPullRequest(worktreePath), true);
|
|
123
|
+
const deleteWorktree = async (worktreePath) => refreshWithStatus(() => adapter.deleteWorktree(worktreePath), false);
|
|
124
|
+
return { setup, start, stop, refresh: adapter.refresh, refreshLogs, openEditor, openPullRequest, deleteWorktree };
|
|
125
|
+
}
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
import { type WorktreeRow } from './git-worktrees.js';
|
|
2
|
+
import { type UpstreamInfo, type WorkingTreeInfo } from './git-metadata.js';
|
|
3
|
+
import { type PullRequestInfo } from './github-metadata.js';
|
|
4
|
+
import { type LogEntry } from './log-reader.js';
|
|
2
5
|
export type RowTag = 'main' | 'active' | 'invalid' | 'external' | 'legacy';
|
|
3
|
-
export interface UpstreamInfo {
|
|
4
|
-
branch: string;
|
|
5
|
-
ahead: number;
|
|
6
|
-
behind: number;
|
|
7
|
-
}
|
|
8
|
-
export interface WorkingTreeInfo {
|
|
9
|
-
staged: number;
|
|
10
|
-
unstaged: number;
|
|
11
|
-
untracked: number;
|
|
12
|
-
conflicts: number;
|
|
13
|
-
}
|
|
14
|
-
export type PullRequestInfo = {
|
|
15
|
-
kind: 'found';
|
|
16
|
-
number: number;
|
|
17
|
-
title: string;
|
|
18
|
-
url: string;
|
|
19
|
-
state: 'OPEN' | 'CLOSED' | 'MERGED';
|
|
20
|
-
isDraft: boolean;
|
|
21
|
-
baseBranch: string;
|
|
22
|
-
} | {
|
|
23
|
-
kind: 'none';
|
|
24
|
-
} | {
|
|
25
|
-
kind: 'unavailable';
|
|
26
|
-
};
|
|
27
6
|
export interface AppRow {
|
|
28
7
|
path: string;
|
|
29
8
|
shortPath: string;
|
|
@@ -34,17 +13,14 @@ export interface AppRow {
|
|
|
34
13
|
upstreamUnavailable?: boolean;
|
|
35
14
|
workingTree?: WorkingTreeInfo;
|
|
36
15
|
pullRequest?: PullRequestInfo;
|
|
16
|
+
branchCreatedAtMs?: number;
|
|
37
17
|
invalidReason?: string;
|
|
38
18
|
}
|
|
39
19
|
export interface AppStatus {
|
|
40
|
-
kind: 'idle' | 'starting' | 'running' | 'stopping' | 'error';
|
|
20
|
+
kind: 'idle' | 'setting-up' | 'starting' | 'running' | 'stopping' | 'error';
|
|
41
21
|
message: string;
|
|
42
22
|
}
|
|
43
|
-
export
|
|
44
|
-
name: string;
|
|
45
|
-
path: string;
|
|
46
|
-
content: string;
|
|
47
|
-
}
|
|
23
|
+
export type AppLogEntry = LogEntry;
|
|
48
24
|
export interface AppModel {
|
|
49
25
|
repoName: string;
|
|
50
26
|
namespace: string;
|
|
@@ -52,21 +28,25 @@ export interface AppModel {
|
|
|
52
28
|
activePath: string | null;
|
|
53
29
|
activeBranch: string | null;
|
|
54
30
|
status: AppStatus;
|
|
31
|
+
setupAvailable: boolean;
|
|
32
|
+
editorAvailable: boolean;
|
|
33
|
+
logs: AppLogEntry[];
|
|
34
|
+
}
|
|
35
|
+
export interface AppLogRefresh {
|
|
55
36
|
logs: AppLogEntry[];
|
|
37
|
+
activePath: string | null;
|
|
38
|
+
activeBranch: string | null;
|
|
56
39
|
}
|
|
57
40
|
export interface AppActions {
|
|
41
|
+
setup: (worktreePath: string) => Promise<AppModel>;
|
|
58
42
|
start: (worktreePath: string) => Promise<AppModel>;
|
|
59
43
|
stop: () => Promise<AppModel>;
|
|
60
44
|
refresh: () => Promise<AppModel>;
|
|
61
|
-
refreshLogs: () => Promise<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
upstreamUnavailable: boolean;
|
|
66
|
-
workingTree?: WorkingTreeInfo;
|
|
45
|
+
refreshLogs: () => Promise<AppLogRefresh>;
|
|
46
|
+
openEditor: (worktreePath: string) => Promise<AppModel>;
|
|
47
|
+
openPullRequest: (worktreePath: string) => Promise<AppModel>;
|
|
48
|
+
deleteWorktree: (worktreePath: string) => Promise<AppModel>;
|
|
67
49
|
}
|
|
68
|
-
export declare function
|
|
69
|
-
export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest'>): AppRow;
|
|
50
|
+
export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest' | 'branchCreatedAtMs'>): AppRow;
|
|
70
51
|
export declare function buildInitialModel(cwd: string): Promise<AppModel>;
|
|
71
52
|
export declare function buildActions(cwd: string): Promise<AppActions>;
|
|
72
|
-
export {};
|
package/dist/core/runtime.js
CHANGED
|
@@ -1,136 +1,36 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
3
|
import { promisify } from 'node:util';
|
|
5
|
-
import { loadToolConfig } from './config.js';
|
|
4
|
+
import { loadToolConfig } from './config-lifecycle.js';
|
|
6
5
|
import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
|
|
6
|
+
import { readGitStatusSummary, readBranchCreatedAtMs, resolveRepoContext } from './git-metadata.js';
|
|
7
|
+
import { readPullRequestInfo } from './github-metadata.js';
|
|
8
|
+
import { readLogs } from './log-reader.js';
|
|
7
9
|
import { getInvalidReason } from './validation.js';
|
|
8
10
|
import { getSessionPaths, readSessionRecord, writeSessionRecord, clearSessionRecord } from './session-store.js';
|
|
9
|
-
import { startDetachedCommand } from './command-runner.js';
|
|
11
|
+
import { runCommandToLog, startDetachedCommand } from './command-runner.js';
|
|
10
12
|
import { stopSessionWithFallback } from './process-control.js';
|
|
11
13
|
import { isProcessGroupAlive, killProcessGroup, killPortOwner, killOrphans } from './posix-process.js';
|
|
14
|
+
import { createRuntimeStateActions } from './runtime-state.js';
|
|
12
15
|
const execFileAsync = promisify(execFile);
|
|
13
16
|
const SHORT_SHA_LENGTH = 8;
|
|
14
|
-
const GH_TIMEOUT_MS = 2500;
|
|
15
|
-
const MAX_LOG_BYTES = 16 * 1024;
|
|
16
|
-
const MAX_LOG_LINES = 120;
|
|
17
17
|
function shortenSha(headSha) {
|
|
18
18
|
return headSha.slice(0, SHORT_SHA_LENGTH);
|
|
19
19
|
}
|
|
20
|
-
function createEmptyWorkingTree() {
|
|
21
|
-
return { staged: 0, unstaged: 0, untracked: 0, conflicts: 0 };
|
|
22
|
-
}
|
|
23
|
-
export function parseGitStatusSummary(output) {
|
|
24
|
-
const workingTree = createEmptyWorkingTree();
|
|
25
|
-
let upstreamBranch;
|
|
26
|
-
let ahead = 0;
|
|
27
|
-
let behind = 0;
|
|
28
|
-
for (const line of output.split('\n')) {
|
|
29
|
-
if (line.startsWith('# branch.upstream ')) {
|
|
30
|
-
upstreamBranch = line.slice('# branch.upstream '.length).trim();
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
if (line.startsWith('# branch.ab ')) {
|
|
34
|
-
const match = /# branch\.ab \+(\d+) -(\d+)/.exec(line);
|
|
35
|
-
ahead = Number(match?.[1] ?? 0);
|
|
36
|
-
behind = Number(match?.[2] ?? 0);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
40
|
-
const [, xy = '..'] = line.split(' ', 3);
|
|
41
|
-
if (xy[0] !== '.') {
|
|
42
|
-
workingTree.staged += 1;
|
|
43
|
-
}
|
|
44
|
-
if (xy[1] !== '.') {
|
|
45
|
-
workingTree.unstaged += 1;
|
|
46
|
-
}
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (line.startsWith('u ')) {
|
|
50
|
-
workingTree.conflicts += 1;
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (line.startsWith('? ')) {
|
|
54
|
-
workingTree.untracked += 1;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
upstream: upstreamBranch ? { branch: upstreamBranch, ahead, behind } : undefined,
|
|
59
|
-
upstreamUnavailable: false,
|
|
60
|
-
workingTree,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
async function readGitStatusSummary(cwd) {
|
|
64
|
-
try {
|
|
65
|
-
const { stdout } = await execFileAsync('git', ['status', '--branch', '--porcelain=v2'], { cwd });
|
|
66
|
-
return parseGitStatusSummary(stdout);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return { upstreamUnavailable: true };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
async function readPullRequestList(cwd, branch, state) {
|
|
73
|
-
const { stdout } = await execFileAsync('gh', [
|
|
74
|
-
'pr',
|
|
75
|
-
'list',
|
|
76
|
-
'--head',
|
|
77
|
-
branch,
|
|
78
|
-
'--state',
|
|
79
|
-
state,
|
|
80
|
-
'--limit',
|
|
81
|
-
'1',
|
|
82
|
-
'--json',
|
|
83
|
-
'number,title,url,state,isDraft,baseRefName',
|
|
84
|
-
], { cwd, timeout: GH_TIMEOUT_MS });
|
|
85
|
-
return JSON.parse(stdout);
|
|
86
|
-
}
|
|
87
|
-
async function readPullRequestInfo(cwd, branch) {
|
|
88
|
-
if (branch.startsWith('(')) {
|
|
89
|
-
return { kind: 'none' };
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
const openPullRequests = await readPullRequestList(cwd, branch, 'open');
|
|
93
|
-
const parsed = openPullRequests.length > 0 ? openPullRequests : await readPullRequestList(cwd, branch, 'all');
|
|
94
|
-
const pr = parsed[0];
|
|
95
|
-
if (!pr) {
|
|
96
|
-
return { kind: 'none' };
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
kind: 'found',
|
|
100
|
-
number: pr.number,
|
|
101
|
-
title: pr.title,
|
|
102
|
-
url: pr.url,
|
|
103
|
-
state: pr.state,
|
|
104
|
-
isDraft: pr.isDraft,
|
|
105
|
-
baseBranch: pr.baseRefName,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return { kind: 'unavailable' };
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
20
|
async function readRowMetadata(worktreePath, branch) {
|
|
113
|
-
const [statusSummary, pullRequest] = await Promise.all([
|
|
21
|
+
const [statusSummary, pullRequest, branchCreatedAtMs] = await Promise.all([
|
|
114
22
|
readGitStatusSummary(worktreePath),
|
|
115
23
|
readPullRequestInfo(worktreePath, branch),
|
|
24
|
+
readBranchCreatedAtMs(worktreePath, branch),
|
|
116
25
|
]);
|
|
117
26
|
return {
|
|
118
27
|
upstream: statusSummary.upstream,
|
|
119
28
|
upstreamUnavailable: statusSummary.upstreamUnavailable,
|
|
120
29
|
workingTree: statusSummary.workingTree,
|
|
121
30
|
pullRequest,
|
|
31
|
+
branchCreatedAtMs: branchCreatedAtMs ?? undefined,
|
|
122
32
|
};
|
|
123
33
|
}
|
|
124
|
-
async function resolveRepoContext(cwd) {
|
|
125
|
-
const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
|
|
126
|
-
execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
|
|
127
|
-
execFileAsync('git', ['rev-parse', '--git-common-dir'], { cwd }),
|
|
128
|
-
]);
|
|
129
|
-
const workspaceRoot = workspaceRootRaw.trim();
|
|
130
|
-
const gitCommonDir = path.resolve(workspaceRoot, gitCommonDirRaw.trim());
|
|
131
|
-
const mainWorktreePath = path.dirname(gitCommonDir);
|
|
132
|
-
return { workspaceRoot, mainWorktreePath, gitCommonDir };
|
|
133
|
-
}
|
|
134
34
|
export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason, metadata) {
|
|
135
35
|
const tags = [];
|
|
136
36
|
if (worktree.isMain) {
|
|
@@ -155,6 +55,7 @@ export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason,
|
|
|
155
55
|
upstreamUnavailable: metadata.upstreamUnavailable,
|
|
156
56
|
workingTree: metadata.workingTree,
|
|
157
57
|
pullRequest: metadata.pullRequest,
|
|
58
|
+
branchCreatedAtMs: metadata.branchCreatedAtMs,
|
|
158
59
|
invalidReason: invalidReason ?? undefined,
|
|
159
60
|
};
|
|
160
61
|
}
|
|
@@ -180,45 +81,42 @@ async function stopRecordedSession(pgid, port, orphanMatchers) {
|
|
|
180
81
|
throw new Error(`Failed to stop existing session pgid=${pgid}`);
|
|
181
82
|
}
|
|
182
83
|
}
|
|
183
|
-
function
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const entries = (await readdir(logsDir, { withFileTypes: true }))
|
|
192
|
-
.filter(entry => entry.isFile() && entry.name.endsWith('.log'))
|
|
193
|
-
.map(entry => ({ name: entry.name, path: path.join(logsDir, entry.name) }));
|
|
194
|
-
if (entries.length === 0) {
|
|
195
|
-
return [];
|
|
196
|
-
}
|
|
197
|
-
let selectedEntries = entries;
|
|
198
|
-
if (activeLogPath !== null) {
|
|
199
|
-
const activeEntry = entries.find(entry => entry.path === activeLogPath);
|
|
200
|
-
if (activeEntry) {
|
|
201
|
-
selectedEntries = [activeEntry];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
const withStats = await Promise.all(entries.map(async (entry) => ({
|
|
206
|
-
...entry,
|
|
207
|
-
mtimeMs: (await stat(entry.path)).mtimeMs,
|
|
208
|
-
})));
|
|
209
|
-
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
210
|
-
selectedEntries = [withStats[0]];
|
|
84
|
+
async function launchDetachedCommand(command, cwd) {
|
|
85
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
86
|
+
let settled = false;
|
|
87
|
+
const child = spawn(command[0], command.slice(1), { cwd, detached: true, stdio: 'ignore' });
|
|
88
|
+
const finalize = (callback) => {
|
|
89
|
+
if (settled) {
|
|
90
|
+
return;
|
|
211
91
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
92
|
+
settled = true;
|
|
93
|
+
callback();
|
|
94
|
+
};
|
|
95
|
+
child.once('error', error => {
|
|
96
|
+
finalize(() => reject(error));
|
|
97
|
+
});
|
|
98
|
+
child.once('spawn', () => {
|
|
99
|
+
finalize(() => {
|
|
100
|
+
child.unref();
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
await promise;
|
|
105
|
+
}
|
|
106
|
+
function getBrowserOpenCommand(url) {
|
|
107
|
+
switch (process.platform) {
|
|
108
|
+
case 'darwin':
|
|
109
|
+
return ['open', url];
|
|
110
|
+
case 'win32':
|
|
111
|
+
return ['cmd', '/c', 'start', '', url];
|
|
112
|
+
default:
|
|
113
|
+
return ['xdg-open', url];
|
|
220
114
|
}
|
|
221
115
|
}
|
|
116
|
+
async function readSelectedWorktree(workspaceRoot, mainWorktreePath, worktreePath) {
|
|
117
|
+
const rows = await readWorktrees(workspaceRoot, mainWorktreePath);
|
|
118
|
+
return rows.find(row => row.path === worktreePath) ?? null;
|
|
119
|
+
}
|
|
222
120
|
export async function buildInitialModel(cwd) {
|
|
223
121
|
const { workspaceRoot, mainWorktreePath, gitCommonDir } = await resolveRepoContext(cwd);
|
|
224
122
|
const config = await loadToolConfig({ repoRoot: workspaceRoot });
|
|
@@ -231,80 +129,78 @@ export async function buildInitialModel(cwd) {
|
|
|
231
129
|
activePath: active?.worktreePath ?? null,
|
|
232
130
|
activeBranch: active?.branch ?? null,
|
|
233
131
|
status: active ? { kind: 'running', message: `Active: ${active.branch}` } : { kind: 'idle', message: 'ready' },
|
|
132
|
+
setupAvailable: config.setupCommand !== undefined,
|
|
133
|
+
editorAvailable: config.editorCommand !== undefined,
|
|
234
134
|
logs: await readLogs(paths.logsDir, active?.logPath ?? null),
|
|
235
135
|
};
|
|
236
136
|
}
|
|
237
137
|
export async function buildActions(cwd) {
|
|
238
|
-
const { workspaceRoot, gitCommonDir } = await resolveRepoContext(cwd);
|
|
138
|
+
const { workspaceRoot, gitCommonDir, mainWorktreePath } = await resolveRepoContext(cwd);
|
|
239
139
|
const config = await loadToolConfig({ repoRoot: workspaceRoot });
|
|
240
140
|
const paths = getSessionPaths(gitCommonDir, config.namespace);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
status: { kind: 'running', message: `started ${selected.branch}` },
|
|
307
|
-
};
|
|
308
|
-
};
|
|
309
|
-
return { start, stop, refresh, refreshLogs };
|
|
141
|
+
return createRuntimeStateActions({
|
|
142
|
+
config,
|
|
143
|
+
paths,
|
|
144
|
+
adapter: {
|
|
145
|
+
refresh: async () => buildInitialModel(cwd),
|
|
146
|
+
readActive: async () => readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive }),
|
|
147
|
+
readLogs: async (logPath) => readLogs(paths.logsDir, logPath),
|
|
148
|
+
readWorktreeBranch: async (worktreePath) => {
|
|
149
|
+
const selected = await readSelectedWorktree(workspaceRoot, mainWorktreePath, worktreePath);
|
|
150
|
+
if (!selected) {
|
|
151
|
+
throw new Error(`Worktree disappeared: ${worktreePath}`);
|
|
152
|
+
}
|
|
153
|
+
return selected.branch;
|
|
154
|
+
},
|
|
155
|
+
getInvalidReason: async (worktreePath) => getInvalidReason(worktreePath, config.requiredFiles),
|
|
156
|
+
runSetup: runCommandToLog,
|
|
157
|
+
startCommand: startDetachedCommand,
|
|
158
|
+
stopSession: async (active) => stopRecordedSession(active.pgid, active.port, config.orphanMatchers),
|
|
159
|
+
clearSession: async () => clearSessionRecord(paths),
|
|
160
|
+
writeSession: async (record) => writeSessionRecord(paths, record),
|
|
161
|
+
openEditor: async (worktreePath) => {
|
|
162
|
+
const selected = await readSelectedWorktree(workspaceRoot, mainWorktreePath, worktreePath);
|
|
163
|
+
if (!selected) {
|
|
164
|
+
return { kind: 'idle', message: 'worktree no longer exists' };
|
|
165
|
+
}
|
|
166
|
+
if (config.editorCommand === undefined) {
|
|
167
|
+
return { kind: 'idle', message: 'editor command is not configured' };
|
|
168
|
+
}
|
|
169
|
+
await launchDetachedCommand([...config.editorCommand, worktreePath], worktreePath);
|
|
170
|
+
return { kind: 'idle', message: `opened editor for ${selected.branch}` };
|
|
171
|
+
},
|
|
172
|
+
openPullRequest: async (worktreePath) => {
|
|
173
|
+
const selected = await readSelectedWorktree(workspaceRoot, mainWorktreePath, worktreePath);
|
|
174
|
+
if (!selected) {
|
|
175
|
+
return { kind: 'idle', message: 'worktree no longer exists' };
|
|
176
|
+
}
|
|
177
|
+
const pullRequest = await readPullRequestInfo(worktreePath, selected.branch);
|
|
178
|
+
if (pullRequest.kind === 'none') {
|
|
179
|
+
return { kind: 'idle', message: `no pull request found for ${selected.branch}` };
|
|
180
|
+
}
|
|
181
|
+
if (pullRequest.kind === 'unavailable') {
|
|
182
|
+
return { kind: 'error', message: `pull request metadata is unavailable for ${selected.branch}` };
|
|
183
|
+
}
|
|
184
|
+
await launchDetachedCommand(getBrowserOpenCommand(pullRequest.url), worktreePath);
|
|
185
|
+
return { kind: 'idle', message: `opened pull request #${pullRequest.number} for ${selected.branch}` };
|
|
186
|
+
},
|
|
187
|
+
deleteWorktree: async (worktreePath) => {
|
|
188
|
+
const selected = await readSelectedWorktree(workspaceRoot, mainWorktreePath, worktreePath);
|
|
189
|
+
if (!selected) {
|
|
190
|
+
return { kind: 'idle', message: 'worktree no longer exists' };
|
|
191
|
+
}
|
|
192
|
+
if (selected.isMain) {
|
|
193
|
+
return { kind: 'idle', message: 'cannot delete the main worktree' };
|
|
194
|
+
}
|
|
195
|
+
const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
|
|
196
|
+
if (active?.worktreePath === worktreePath) {
|
|
197
|
+
await stopRecordedSession(active.pgid, active.port, config.orphanMatchers);
|
|
198
|
+
await clearSessionRecord(paths);
|
|
199
|
+
}
|
|
200
|
+
await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: workspaceRoot });
|
|
201
|
+
return { kind: 'idle', message: `deleted ${selected.branch}` };
|
|
202
|
+
},
|
|
203
|
+
nowIso: () => new Date().toISOString(),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
310
206
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AppRow, AppStatus } from './runtime.js';
|
|
2
|
+
export type EnterInteractionDecision = {
|
|
3
|
+
kind: 'ignore';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'set-status';
|
|
6
|
+
status: AppStatus;
|
|
7
|
+
suppressesBackgroundRefreshes: true;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'start';
|
|
10
|
+
path: string;
|
|
11
|
+
status: AppStatus;
|
|
12
|
+
};
|
|
13
|
+
export type SetupInteractionDecision = {
|
|
14
|
+
kind: 'ignore';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'setup';
|
|
17
|
+
path: string;
|
|
18
|
+
status: AppStatus;
|
|
19
|
+
};
|
|
20
|
+
export interface AsyncInteractionState {
|
|
21
|
+
generation: number;
|
|
22
|
+
currentGeneration: number;
|
|
23
|
+
userActionInFlight: boolean;
|
|
24
|
+
blocksInput: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function getNextSelectedPath(rows: readonly AppRow[], currentPath: string | null): string | null;
|
|
27
|
+
export declare function getSelectedIndex(rows: readonly AppRow[], selectedPath: string | null): number;
|
|
28
|
+
export declare function clampSelectionIndex(nextIndex: number, rowCount: number): number | null;
|
|
29
|
+
export declare function decideEnterInteraction(selected: AppRow | undefined, activePath: string | null): EnterInteractionDecision;
|
|
30
|
+
export declare function decideSetupInteraction(selected: AppRow | undefined, setupAvailable: boolean): SetupInteractionDecision;
|
|
31
|
+
export declare function shouldApplyAsyncResult(state: AsyncInteractionState): boolean;
|