@ohzw/worktree-command-tui 0.1.0 → 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.
Files changed (49) hide show
  1. package/README.md +5 -0
  2. package/dist/app.d.ts +3 -2
  3. package/dist/app.js +458 -42
  4. package/dist/components/ActionPanel.d.ts +6 -2
  5. package/dist/components/ActionPanel.js +141 -82
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +37 -4
  8. package/dist/components/FloatingLogWindow.d.ts +7 -0
  9. package/dist/components/FloatingLogWindow.js +5 -0
  10. package/dist/components/HelpWindow.d.ts +7 -0
  11. package/dist/components/HelpWindow.js +29 -0
  12. package/dist/components/LogPanel.d.ts +22 -0
  13. package/dist/components/LogPanel.js +260 -0
  14. package/dist/components/WorktreeList.d.ts +3 -1
  15. package/dist/components/WorktreeList.js +25 -30
  16. package/dist/core/command-runner.d.ts +11 -0
  17. package/dist/core/command-runner.js +44 -0
  18. package/dist/core/config-lifecycle.d.ts +25 -0
  19. package/dist/core/config-lifecycle.js +143 -0
  20. package/dist/core/config.d.ts +2 -3
  21. package/dist/core/config.js +0 -48
  22. package/dist/core/git-metadata.d.ts +25 -0
  23. package/dist/core/git-metadata.js +84 -0
  24. package/dist/core/git-worktrees.d.ts +2 -1
  25. package/dist/core/git-worktrees.js +30 -11
  26. package/dist/core/github-metadata.d.ts +14 -0
  27. package/dist/core/github-metadata.js +137 -0
  28. package/dist/core/init.d.ts +3 -2
  29. package/dist/core/init.js +9 -57
  30. package/dist/core/log-reader.d.ts +7 -0
  31. package/dist/core/log-reader.js +43 -0
  32. package/dist/core/runtime-state.d.ts +42 -0
  33. package/dist/core/runtime-state.js +125 -0
  34. package/dist/core/runtime.d.ts +20 -33
  35. package/dist/core/runtime.js +116 -173
  36. package/dist/core/tui-interaction.d.ts +31 -0
  37. package/dist/core/tui-interaction.js +59 -0
  38. package/dist/core/worktree-projection.d.ts +76 -0
  39. package/dist/core/worktree-projection.js +124 -0
  40. package/dist/main.js +24 -2
  41. package/dist/render-options.d.ts +1 -0
  42. package/dist/render-options.js +1 -0
  43. package/dist/repro.d.ts +1 -0
  44. package/dist/repro.js +13 -0
  45. package/dist/terminal/viewport.d.ts +15 -0
  46. package/dist/terminal/viewport.js +49 -0
  47. package/dist/ui-theme.d.ts +3 -0
  48. package/dist/ui-theme.js +38 -0
  49. package/package.json +2 -1
@@ -0,0 +1,25 @@
1
+ export interface UpstreamInfo {
2
+ branch: string;
3
+ ahead: number;
4
+ behind: number;
5
+ }
6
+ export interface WorkingTreeInfo {
7
+ staged: number;
8
+ unstaged: number;
9
+ untracked: number;
10
+ conflicts: number;
11
+ }
12
+ export interface GitStatusSummary {
13
+ upstream?: UpstreamInfo;
14
+ upstreamUnavailable: boolean;
15
+ workingTree?: WorkingTreeInfo;
16
+ }
17
+ export interface RepoContext {
18
+ workspaceRoot: string;
19
+ mainWorktreePath: string;
20
+ gitCommonDir: string;
21
+ }
22
+ export declare function parseGitStatusSummary(output: string): GitStatusSummary;
23
+ export declare function readGitStatusSummary(cwd: string): Promise<GitStatusSummary>;
24
+ export declare function readBranchCreatedAtMs(cwd: string, branch: string): Promise<number | null>;
25
+ export declare function resolveRepoContext(cwd: string): Promise<RepoContext>;
@@ -0,0 +1,84 @@
1
+ import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ function createEmptyWorkingTree() {
6
+ return { staged: 0, unstaged: 0, untracked: 0, conflicts: 0 };
7
+ }
8
+ export function parseGitStatusSummary(output) {
9
+ const workingTree = createEmptyWorkingTree();
10
+ let upstreamBranch;
11
+ let ahead = 0;
12
+ let behind = 0;
13
+ for (const line of output.split('\n')) {
14
+ if (line.startsWith('# branch.upstream ')) {
15
+ upstreamBranch = line.slice('# branch.upstream '.length).trim();
16
+ continue;
17
+ }
18
+ if (line.startsWith('# branch.ab ')) {
19
+ const match = /# branch\.ab \+(\d+) -(\d+)/.exec(line);
20
+ ahead = Number(match?.[1] ?? 0);
21
+ behind = Number(match?.[2] ?? 0);
22
+ continue;
23
+ }
24
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
25
+ const [, xy = '..'] = line.split(' ', 3);
26
+ if (xy[0] !== '.') {
27
+ workingTree.staged += 1;
28
+ }
29
+ if (xy[1] !== '.') {
30
+ workingTree.unstaged += 1;
31
+ }
32
+ continue;
33
+ }
34
+ if (line.startsWith('u ')) {
35
+ workingTree.conflicts += 1;
36
+ continue;
37
+ }
38
+ if (line.startsWith('? ')) {
39
+ workingTree.untracked += 1;
40
+ }
41
+ }
42
+ return {
43
+ upstream: upstreamBranch ? { branch: upstreamBranch, ahead, behind } : undefined,
44
+ upstreamUnavailable: false,
45
+ workingTree,
46
+ };
47
+ }
48
+ export async function readGitStatusSummary(cwd) {
49
+ try {
50
+ const { stdout } = await execFileAsync('git', ['status', '--branch', '--porcelain=v2'], { cwd });
51
+ return parseGitStatusSummary(stdout);
52
+ }
53
+ catch {
54
+ return { upstreamUnavailable: true };
55
+ }
56
+ }
57
+ export async function readBranchCreatedAtMs(cwd, branch) {
58
+ if (branch.startsWith('(')) {
59
+ return null;
60
+ }
61
+ try {
62
+ const { stdout } = await execFileAsync('git', ['reflog', 'show', '--format=%ct', `refs/heads/${branch}`], { cwd });
63
+ const trimmed = stdout.trim();
64
+ if (trimmed.length === 0) {
65
+ return null;
66
+ }
67
+ const timestamps = trimmed.split('\n');
68
+ const firstTimestampSeconds = Number(timestamps.at(-1));
69
+ return Number.isFinite(firstTimestampSeconds) ? firstTimestampSeconds * 1000 : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ export async function resolveRepoContext(cwd) {
76
+ const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
77
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
78
+ execFileAsync('git', ['rev-parse', '--git-common-dir'], { cwd }),
79
+ ]);
80
+ const workspaceRoot = workspaceRootRaw.trim();
81
+ const gitCommonDir = path.resolve(workspaceRoot, gitCommonDirRaw.trim());
82
+ const mainWorktreePath = path.dirname(gitCommonDir);
83
+ return { workspaceRoot, mainWorktreePath, gitCommonDir };
84
+ }
@@ -4,8 +4,9 @@ export interface WorktreeRow {
4
4
  headSha: string;
5
5
  isMain: boolean;
6
6
  isExternal: boolean;
7
+ createdAtMs: number | null;
7
8
  }
8
9
  export declare function parseWorktreeListPorcelain(input: string, mainWorktreePath: string): WorktreeRow[];
9
- export declare function sortWorktrees(rows: WorktreeRow[], activePath: string | null): WorktreeRow[];
10
+ export declare function sortWorktrees(rows: WorktreeRow[], _activePath: string | null): WorktreeRow[];
10
11
  export declare function readWorktrees(cwd: string, mainWorktreePath: string): Promise<WorktreeRow[]>;
11
12
  export declare function toShortPath(mainWorktreePath: string, worktreePath: string): string;
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { stat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { promisify } from 'node:util';
4
5
  const execFileAsync = promisify(execFile);
@@ -28,26 +29,44 @@ export function parseWorktreeListPorcelain(input, mainWorktreePath) {
28
29
  headSha: headLine?.slice('HEAD '.length) ?? '',
29
30
  isMain: worktreePath === mainWorktreePath,
30
31
  isExternal: isExternalWorktree(mainWorktreePath, worktreePath),
32
+ createdAtMs: null,
31
33
  };
32
34
  });
33
35
  }
34
- export function sortWorktrees(rows, activePath) {
36
+ function compareDeterministic(left, right) {
37
+ if (left.isMain !== right.isMain) {
38
+ return left.isMain ? -1 : 1;
39
+ }
40
+ const branchCompare = left.branch.localeCompare(right.branch);
41
+ return branchCompare !== 0 ? branchCompare : left.path.localeCompare(right.path);
42
+ }
43
+ export function sortWorktrees(rows, _activePath) {
44
+ const hasMissingCreatedAt = rows.some(row => row.createdAtMs === null);
35
45
  return [...rows].sort((left, right) => {
36
- if (left.isMain !== right.isMain) {
37
- return left.isMain ? -1 : 1;
38
- }
39
- const leftActive = activePath !== null && left.path === activePath;
40
- const rightActive = activePath !== null && right.path === activePath;
41
- if (leftActive !== rightActive) {
42
- return leftActive ? -1 : 1;
46
+ const leftCreated = left.createdAtMs;
47
+ const rightCreated = right.createdAtMs;
48
+ if (!hasMissingCreatedAt && leftCreated !== null && rightCreated !== null && leftCreated !== rightCreated) {
49
+ return leftCreated - rightCreated;
43
50
  }
44
- const branchCompare = left.branch.localeCompare(right.branch);
45
- return branchCompare !== 0 ? branchCompare : left.path.localeCompare(right.path);
51
+ return compareDeterministic(left, right);
46
52
  });
47
53
  }
54
+ async function readWorktreeCreatedAtMs(worktreePath) {
55
+ try {
56
+ const stats = await stat(worktreePath);
57
+ return stats.birthtimeMs;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
48
63
  export async function readWorktrees(cwd, mainWorktreePath) {
49
64
  const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd });
50
- return parseWorktreeListPorcelain(stdout, mainWorktreePath);
65
+ const rows = parseWorktreeListPorcelain(stdout, mainWorktreePath);
66
+ return Promise.all(rows.map(async (row) => ({
67
+ ...row,
68
+ createdAtMs: await readWorktreeCreatedAtMs(row.path),
69
+ })));
51
70
  }
52
71
  export function toShortPath(mainWorktreePath, worktreePath) {
53
72
  if (worktreePath === mainWorktreePath) {
@@ -0,0 +1,14 @@
1
+ export type PullRequestInfo = {
2
+ kind: 'found';
3
+ number: number;
4
+ title: string;
5
+ url: string;
6
+ state: 'OPEN' | 'CLOSED' | 'MERGED';
7
+ isDraft: boolean;
8
+ baseBranch: string;
9
+ } | {
10
+ kind: 'none';
11
+ } | {
12
+ kind: 'unavailable';
13
+ };
14
+ export declare function readPullRequestInfo(cwd: string, branch: string): Promise<PullRequestInfo>;
@@ -0,0 +1,137 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const GH_TIMEOUT_MS = 2500;
5
+ const SCP_REMOTE_URL_RE = /^(?:[^@]+@)?([^:]+):(.+)$/;
6
+ function splitRepositoryPath(pathInput) {
7
+ return pathInput
8
+ .replace(/\.git$/, '')
9
+ .replace(/^\/+/, '')
10
+ .replace(/\/+$/, '')
11
+ .split('/')
12
+ .filter(Boolean);
13
+ }
14
+ function parseGitHubRepositoryFromRemoteUrl(remoteUrl) {
15
+ const trimmedUrl = remoteUrl.trim();
16
+ if (!trimmedUrl) {
17
+ return null;
18
+ }
19
+ if (trimmedUrl.includes('://')) {
20
+ if (!URL.canParse(trimmedUrl)) {
21
+ return null;
22
+ }
23
+ const parsedUrl = new URL(trimmedUrl);
24
+ const segments = splitRepositoryPath(parsedUrl.pathname);
25
+ if (segments.length < 2) {
26
+ return null;
27
+ }
28
+ return { host: parsedUrl.hostname.toLowerCase(), owner: segments[0], name: segments[1] };
29
+ }
30
+ const scpMatch = SCP_REMOTE_URL_RE.exec(trimmedUrl);
31
+ if (!scpMatch) {
32
+ return null;
33
+ }
34
+ const segments = splitRepositoryPath(scpMatch[2]);
35
+ if (segments.length < 2) {
36
+ return null;
37
+ }
38
+ return { host: scpMatch[1].toLowerCase(), owner: segments[0], name: segments[1] };
39
+ }
40
+ async function readGitHubRepository(cwd) {
41
+ const { stdout } = await execFileAsync('git', ['config', '--get', 'remote.origin.url'], { cwd });
42
+ const repository = parseGitHubRepositoryFromRemoteUrl(stdout);
43
+ if (!repository) {
44
+ throw new Error('GitHub repository remote unavailable');
45
+ }
46
+ return repository;
47
+ }
48
+ function buildPullRequestListArgs(repository, branch, state) {
49
+ const args = [
50
+ 'api',
51
+ '-X',
52
+ 'GET',
53
+ `repos/${repository.owner}/${repository.name}/pulls`,
54
+ '-f',
55
+ `state=${state}`,
56
+ '-f',
57
+ `head=${repository.owner}:${branch}`,
58
+ '-F',
59
+ 'per_page=1',
60
+ ];
61
+ if (repository.host !== 'github.com' && repository.host !== 'www.github.com') {
62
+ args.push('--hostname', repository.host);
63
+ }
64
+ return args;
65
+ }
66
+ function normalizePullRequestState(state, mergedAt) {
67
+ if (typeof state === 'string' && state.toUpperCase() === 'OPEN') {
68
+ return 'OPEN';
69
+ }
70
+ return typeof mergedAt === 'string' && mergedAt.length > 0 ? 'MERGED' : 'CLOSED';
71
+ }
72
+ function parsePullRequest(item) {
73
+ if (typeof item !== 'object' || item === null) {
74
+ return null;
75
+ }
76
+ const pullRequest = item;
77
+ const number = typeof pullRequest.number === 'number' ? pullRequest.number : NaN;
78
+ const title = typeof pullRequest.title === 'string' ? pullRequest.title : '';
79
+ const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : '';
80
+ const isDraft = typeof pullRequest.draft === 'boolean' ? pullRequest.draft : false;
81
+ const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : '';
82
+ if (!Number.isFinite(number) || !title || !url || !baseRefName) {
83
+ return null;
84
+ }
85
+ return {
86
+ number,
87
+ title,
88
+ url,
89
+ state: normalizePullRequestState(pullRequest.state, pullRequest.merged_at),
90
+ isDraft,
91
+ baseRefName,
92
+ };
93
+ }
94
+ async function readPullRequestList(cwd, branch, state) {
95
+ const repository = await readGitHubRepository(cwd);
96
+ const args = buildPullRequestListArgs(repository, branch, state);
97
+ const { stdout } = await execFileAsync('gh', args, { cwd, timeout: GH_TIMEOUT_MS });
98
+ const payload = JSON.parse(stdout);
99
+ if (!Array.isArray(payload)) {
100
+ throw new Error('GitHub REST API returned unexpected payload');
101
+ }
102
+ const pullRequests = [];
103
+ for (const item of payload) {
104
+ const parsed = parsePullRequest(item);
105
+ if (parsed !== null) {
106
+ pullRequests.push(parsed);
107
+ }
108
+ }
109
+ return pullRequests;
110
+ }
111
+ export async function readPullRequestInfo(cwd, branch) {
112
+ if (branch.startsWith('(')) {
113
+ return { kind: 'none' };
114
+ }
115
+ try {
116
+ const openPullRequests = await readPullRequestList(cwd, branch, 'open');
117
+ const pullRequests = openPullRequests.length > 0
118
+ ? openPullRequests
119
+ : await readPullRequestList(cwd, branch, 'all');
120
+ const pullRequest = pullRequests[0];
121
+ if (!pullRequest) {
122
+ return { kind: 'none' };
123
+ }
124
+ return {
125
+ kind: 'found',
126
+ number: pullRequest.number,
127
+ title: pullRequest.title,
128
+ url: pullRequest.url,
129
+ state: pullRequest.state,
130
+ isDraft: pullRequest.isDraft,
131
+ baseBranch: pullRequest.baseRefName,
132
+ };
133
+ }
134
+ catch {
135
+ return { kind: 'unavailable' };
136
+ }
137
+ }
@@ -1,6 +1,7 @@
1
- import { type ToolConfig } from './config.js';
1
+ import type { ToolConfig } from './config.js';
2
+ import { renderConfigJsonc } from './config-lifecycle.js';
3
+ export { renderConfigJsonc };
2
4
  export declare function buildDefaultConfig(repoRoot: string): Promise<ToolConfig>;
3
- export declare function renderConfigJsonc(config: ToolConfig): string;
4
5
  export interface InitResult {
5
6
  path: string;
6
7
  config: ToolConfig;
package/dist/core/init.js CHANGED
@@ -1,18 +1,11 @@
1
- import { access, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, readFile } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
- import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './config.js';
6
+ import { createDefaultToolConfig, renderConfigJsonc, writeToolConfigForRepo } from './config-lifecycle.js';
7
7
  const execFileAsync = promisify(execFile);
8
- function isSafeNamespace(value) {
9
- return /^[A-Za-z0-9._-]+$/u.test(value);
10
- }
11
- function toSafeNamespace(value) {
12
- const replaced = value.replace(/[^A-Za-z0-9._-]+/gu, '-');
13
- const trimmed = replaced.replace(/^-+/, '').replace(/-+$/, '');
14
- return trimmed.length > 0 && isSafeNamespace(trimmed) ? trimmed : 'worktree-command-tui';
15
- }
8
+ export { renderConfigJsonc };
16
9
  async function resolveRepositoryRoot(cwd) {
17
10
  const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd });
18
11
  return stdout.trim();
@@ -85,56 +78,15 @@ function selectDefaultScript(scripts) {
85
78
  export async function buildDefaultConfig(repoRoot) {
86
79
  const packageJson = await readPackageJson(repoRoot);
87
80
  const packageManager = await detectPackageManager(repoRoot, packageJson);
88
- const command = [packageManager, 'run', selectDefaultScript(packageJson?.scripts)];
89
- const namespaceSeed = packageJson?.name ?? path.basename(repoRoot);
90
- return {
91
- namespace: toSafeNamespace(namespaceSeed),
92
- command,
93
- port: 3000,
94
- requiredFiles: ['package.json'],
95
- orphanMatchers: [],
96
- };
97
- }
98
- export function renderConfigJsonc(config) {
99
- return `{
100
- // Session namespace used for git-common-dir state files and logs.
101
- // Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
102
- "namespace": ${JSON.stringify(config.namespace)},
103
-
104
- // Command launched in the selected worktree.
105
- // Use argv form so spaces and shell metacharacters are passed safely.
106
- "command": ${JSON.stringify(config.command)},
107
-
108
- // TCP port owned by the command, used when stopping stale/orphaned processes.
109
- "port": ${JSON.stringify(config.port)},
110
-
111
- // Files that must exist in a worktree before the command can be started there.
112
- "requiredFiles": ${JSON.stringify(config.requiredFiles)},
113
-
114
- // Extra process command-line substrings treated as orphans for cleanup.
115
- // Example: ["node --watch", "vite --host 0.0.0.0"]
116
- "orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
117
- }
118
- `;
119
- }
120
- async function findExistingConfigPath(workspaceRoot) {
121
- for (const fileName of CONFIG_FILE_NAMES) {
122
- const configPath = path.join(workspaceRoot, fileName);
123
- if (await fileExists(configPath)) {
124
- return configPath;
125
- }
126
- }
127
- return null;
81
+ return createDefaultToolConfig({
82
+ namespaceSeed: packageJson?.name ?? path.basename(repoRoot),
83
+ packageManager,
84
+ script: selectDefaultScript(packageJson?.scripts),
85
+ });
128
86
  }
129
87
  export async function runInit(options) {
130
- const configPath = path.join(options.workspaceRoot, CONFIG_FILE_NAME);
131
- const existingConfigPath = await findExistingConfigPath(options.workspaceRoot);
132
- if (!options.force && existingConfigPath) {
133
- throw new Error(`Config file already exists: ${existingConfigPath}`);
134
- }
135
88
  const config = await buildDefaultConfig(options.workspaceRoot);
136
- await writeFile(configPath, renderConfigJsonc(config), 'utf8');
137
- return { path: configPath, config };
89
+ return writeToolConfigForRepo({ ...options, config }, fileExists);
138
90
  }
139
91
  export async function createConfigForRepo(options) {
140
92
  const workspaceRoot = await resolveRepositoryRoot(options.cwd);
@@ -0,0 +1,7 @@
1
+ export interface LogEntry {
2
+ name: string;
3
+ path: string;
4
+ content: string;
5
+ }
6
+ export declare function tailLogContent(content: string): string;
7
+ export declare function readLogs(logsDir: string, activeLogPath: string | null): Promise<LogEntry[]>;
@@ -0,0 +1,43 @@
1
+ import path from 'node:path';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ const MAX_LOG_BYTES = 16 * 1024;
4
+ const MAX_LOG_LINES = 120;
5
+ export function tailLogContent(content) {
6
+ const byteTrimmed = content.length > MAX_LOG_BYTES ? content.slice(-MAX_LOG_BYTES) : content;
7
+ const lines = byteTrimmed.replace(/\r\n/g, '\n').split('\n');
8
+ const tailLines = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines;
9
+ return tailLines.join('\n').trimEnd();
10
+ }
11
+ export async function readLogs(logsDir, activeLogPath) {
12
+ try {
13
+ const entries = (await readdir(logsDir, { withFileTypes: true }))
14
+ .filter(entry => entry.isFile() && entry.name.endsWith('.log'))
15
+ .map(entry => ({ name: entry.name, path: path.join(logsDir, entry.name) }));
16
+ if (entries.length === 0) {
17
+ return [];
18
+ }
19
+ let selectedEntries = entries;
20
+ if (activeLogPath !== null) {
21
+ const activeEntry = entries.find(entry => entry.path === activeLogPath);
22
+ if (activeEntry) {
23
+ selectedEntries = [activeEntry];
24
+ }
25
+ }
26
+ else {
27
+ const withStats = await Promise.all(entries.map(async (entry) => ({
28
+ ...entry,
29
+ mtimeMs: (await stat(entry.path)).mtimeMs,
30
+ })));
31
+ withStats.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
32
+ selectedEntries = [withStats[0]];
33
+ }
34
+ return await Promise.all(selectedEntries.map(async (entry) => ({
35
+ name: entry.name,
36
+ path: entry.path,
37
+ content: tailLogContent(await readFile(entry.path, 'utf8')),
38
+ })));
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import type { ToolConfig } from './config.js';
2
+ import type { AppActions, AppLogEntry, AppModel, AppStatus } from './runtime.js';
3
+ import type { SessionPaths, SessionRecord } from './session-store.js';
4
+ export interface StartedCommand {
5
+ pid: number;
6
+ pgid: number;
7
+ logPath: string;
8
+ }
9
+ export interface RuntimeStateAdapter {
10
+ refresh: () => Promise<AppModel>;
11
+ readActive: () => Promise<SessionRecord | null>;
12
+ readLogs: (logPath: string | null) => Promise<AppLogEntry[]>;
13
+ readWorktreeBranch: (worktreePath: string) => Promise<string>;
14
+ getInvalidReason: (worktreePath: string) => Promise<string | null>;
15
+ runSetup: (input: {
16
+ command: string[];
17
+ cwd: string;
18
+ logsDir: string;
19
+ logFileBase: string;
20
+ }) => Promise<{
21
+ logPath: string;
22
+ }>;
23
+ startCommand: (input: {
24
+ command: string[];
25
+ cwd: string;
26
+ logsDir: string;
27
+ logFileBase: string;
28
+ }) => Promise<StartedCommand>;
29
+ stopSession: (active: SessionRecord) => Promise<void>;
30
+ clearSession: () => Promise<void>;
31
+ writeSession: (record: SessionRecord) => Promise<void>;
32
+ openEditor: (worktreePath: string) => Promise<AppStatus>;
33
+ openPullRequest: (worktreePath: string) => Promise<AppStatus>;
34
+ deleteWorktree: (worktreePath: string) => Promise<AppStatus>;
35
+ nowIso: () => string;
36
+ }
37
+ export interface RuntimeStateOptions {
38
+ config: ToolConfig;
39
+ paths: SessionPaths;
40
+ adapter: RuntimeStateAdapter;
41
+ }
42
+ export declare function createRuntimeStateActions({ config, paths, adapter }: RuntimeStateOptions): AppActions;
@@ -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
+ }