@ohzw/worktree-command-tui 0.1.1 → 0.1.3

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 (46) hide show
  1. package/README.md +91 -26
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +242 -114
  4. package/dist/components/ActionPanel.d.ts +4 -2
  5. package/dist/components/ActionPanel.js +87 -135
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +29 -3
  8. package/dist/components/Header.js +5 -1
  9. package/dist/components/HelpWindow.d.ts +7 -0
  10. package/dist/components/HelpWindow.js +29 -0
  11. package/dist/components/LogPanel.d.ts +10 -3
  12. package/dist/components/LogPanel.js +240 -33
  13. package/dist/components/WorktreeList.js +20 -40
  14. package/dist/core/command-runner.d.ts +11 -0
  15. package/dist/core/command-runner.js +59 -7
  16. package/dist/core/config-lifecycle.d.ts +25 -0
  17. package/dist/core/config-lifecycle.js +160 -0
  18. package/dist/core/config.d.ts +2 -3
  19. package/dist/core/config.js +0 -48
  20. package/dist/core/git-metadata.d.ts +25 -0
  21. package/dist/core/git-metadata.js +84 -0
  22. package/dist/core/git-worktrees.d.ts +2 -1
  23. package/dist/core/git-worktrees.js +30 -11
  24. package/dist/core/github-metadata.d.ts +21 -0
  25. package/dist/core/github-metadata.js +153 -0
  26. package/dist/core/init.d.ts +3 -2
  27. package/dist/core/init.js +9 -57
  28. package/dist/core/log-reader.d.ts +7 -0
  29. package/dist/core/log-reader.js +59 -0
  30. package/dist/core/posix-process.d.ts +2 -2
  31. package/dist/core/posix-process.js +19 -4
  32. package/dist/core/process-control.d.ts +2 -2
  33. package/dist/core/process-control.js +5 -2
  34. package/dist/core/runtime-state.d.ts +42 -0
  35. package/dist/core/runtime-state.js +125 -0
  36. package/dist/core/runtime.d.ts +19 -39
  37. package/dist/core/runtime.js +112 -216
  38. package/dist/core/session-store.js +22 -7
  39. package/dist/core/tui-interaction.d.ts +31 -0
  40. package/dist/core/tui-interaction.js +59 -0
  41. package/dist/core/worktree-projection.d.ts +76 -0
  42. package/dist/core/worktree-projection.js +132 -0
  43. package/dist/main.js +6 -5
  44. package/dist/terminal/viewport.d.ts +15 -0
  45. package/dist/terminal/viewport.js +49 -0
  46. package/package.json +1 -1
@@ -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 tailLogContent(content) {
184
- const byteTrimmed = content.length > MAX_LOG_BYTES ? content.slice(-MAX_LOG_BYTES) : content;
185
- const lines = byteTrimmed.replace(/\r\n/g, '\n').split('\n');
186
- const tailLines = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines;
187
- return tailLines.join('\n').trimEnd();
188
- }
189
- async function readLogs(logsDir, activeLogPath) {
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
- return await Promise.all(selectedEntries.map(async (entry) => ({
213
- name: entry.name,
214
- path: entry.path,
215
- content: tailLogContent(await readFile(entry.path, 'utf8')),
216
- })));
217
- }
218
- catch {
219
- return [];
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
- const mainWorktreePath = path.dirname(gitCommonDir);
242
- const refresh = async () => buildInitialModel(cwd);
243
- const refreshLogs = async () => {
244
- const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
245
- return readLogs(paths.logsDir, active?.logPath ?? null);
246
- };
247
- const stop = async () => {
248
- const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
249
- if (active) {
250
- await stopRecordedSession(active.pgid, active.port, config.orphanMatchers);
251
- await clearSessionRecord(paths);
252
- }
253
- const model = await refresh();
254
- return {
255
- ...model,
256
- activePath: null,
257
- activeBranch: null,
258
- status: { kind: 'idle', message: active ? 'stopped' : 'already stopped' },
259
- };
260
- };
261
- const start = async (worktreePath) => {
262
- const current = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
263
- if (current?.worktreePath === worktreePath) {
264
- const model = await refresh();
265
- return {
266
- ...model,
267
- activePath: current.worktreePath,
268
- activeBranch: current.branch,
269
- status: { kind: 'idle', message: 'already active' },
270
- };
271
- }
272
- const invalidReason = await getInvalidReason(worktreePath, config.requiredFiles);
273
- if (invalidReason) {
274
- throw new Error(invalidReason);
275
- }
276
- if (current) {
277
- await stopRecordedSession(current.pgid, current.port, config.orphanMatchers);
278
- await clearSessionRecord(paths);
279
- }
280
- const rows = await readWorktrees(workspaceRoot, mainWorktreePath);
281
- const selected = rows.find(row => row.path === worktreePath);
282
- if (!selected) {
283
- throw new Error(`Worktree disappeared: ${worktreePath}`);
284
- }
285
- const started = await startDetachedCommand({
286
- command: config.command,
287
- cwd: worktreePath,
288
- logsDir: paths.logsDir,
289
- logFileBase: selected.branch.replace(/[\\/]/g, '-'),
290
- });
291
- await writeSessionRecord(paths, {
292
- namespace: config.namespace,
293
- worktreePath,
294
- branch: selected.branch,
295
- pid: started.pid,
296
- pgid: started.pgid,
297
- port: config.port,
298
- logPath: started.logPath,
299
- startedAt: new Date().toISOString(),
300
- });
301
- const model = await refresh();
302
- return {
303
- ...model,
304
- activePath: worktreePath,
305
- activeBranch: selected.branch,
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
  }
@@ -1,7 +1,11 @@
1
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- function isPositiveInteger(value) {
4
- return typeof value === 'number' && Number.isInteger(value) && value > 0;
3
+ const MAX_SESSION_BYTES = 16 * 1024;
4
+ function isSafeProcessId(value) {
5
+ return typeof value === 'number' && Number.isInteger(value) && value > 1;
6
+ }
7
+ function isValidPort(value) {
8
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535;
5
9
  }
6
10
  function isSessionRecord(value) {
7
11
  if (typeof value !== 'object' || value === null) {
@@ -11,9 +15,9 @@ function isSessionRecord(value) {
11
15
  return (typeof record.namespace === 'string' &&
12
16
  typeof record.worktreePath === 'string' &&
13
17
  typeof record.branch === 'string' &&
14
- isPositiveInteger(record.pid) &&
15
- isPositiveInteger(record.pgid) &&
16
- isPositiveInteger(record.port) &&
18
+ isSafeProcessId(record.pid) &&
19
+ isSafeProcessId(record.pgid) &&
20
+ isValidPort(record.port) &&
17
21
  typeof record.logPath === 'string' &&
18
22
  typeof record.startedAt === 'string');
19
23
  }
@@ -25,9 +29,20 @@ export function getSessionPaths(gitCommonDir, namespace) {
25
29
  sessionFile: path.join(baseDir, `${namespace}.json`),
26
30
  };
27
31
  }
32
+ async function readSessionFile(sessionFile) {
33
+ if ((await stat(sessionFile)).size > MAX_SESSION_BYTES) {
34
+ await rm(sessionFile, { force: true });
35
+ return null;
36
+ }
37
+ return readFile(sessionFile, 'utf8');
38
+ }
28
39
  export async function readSessionRecord(paths, { isSessionAlive }) {
29
40
  try {
30
- const parsed = JSON.parse(await readFile(paths.sessionFile, 'utf8'));
41
+ const source = await readSessionFile(paths.sessionFile);
42
+ if (source === null) {
43
+ return null;
44
+ }
45
+ const parsed = JSON.parse(source);
31
46
  if (!isSessionRecord(parsed)) {
32
47
  await rm(paths.sessionFile, { force: true });
33
48
  return null;
@@ -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;
@@ -0,0 +1,59 @@
1
+ export function getNextSelectedPath(rows, currentPath) {
2
+ if (rows.length === 0) {
3
+ return null;
4
+ }
5
+ if (currentPath && rows.some(row => row.path === currentPath)) {
6
+ return currentPath;
7
+ }
8
+ return rows[0].path;
9
+ }
10
+ export function getSelectedIndex(rows, selectedPath) {
11
+ if (selectedPath === null) {
12
+ return 0;
13
+ }
14
+ const foundIndex = rows.findIndex(row => row.path === selectedPath);
15
+ return foundIndex >= 0 ? foundIndex : 0;
16
+ }
17
+ export function clampSelectionIndex(nextIndex, rowCount) {
18
+ if (rowCount <= 0) {
19
+ return null;
20
+ }
21
+ return Math.min(Math.max(nextIndex, 0), rowCount - 1);
22
+ }
23
+ export function decideEnterInteraction(selected, activePath) {
24
+ if (selected === undefined) {
25
+ return { kind: 'ignore' };
26
+ }
27
+ if (selected.invalidReason) {
28
+ return {
29
+ kind: 'set-status',
30
+ status: { kind: 'error', message: selected.invalidReason },
31
+ suppressesBackgroundRefreshes: true,
32
+ };
33
+ }
34
+ if (selected.path === activePath) {
35
+ return {
36
+ kind: 'set-status',
37
+ status: { kind: 'idle', message: 'already active' },
38
+ suppressesBackgroundRefreshes: true,
39
+ };
40
+ }
41
+ return {
42
+ kind: 'start',
43
+ path: selected.path,
44
+ status: { kind: 'starting', message: `Starting ${selected.branch}...` },
45
+ };
46
+ }
47
+ export function decideSetupInteraction(selected, setupAvailable) {
48
+ if (selected === undefined || !setupAvailable) {
49
+ return { kind: 'ignore' };
50
+ }
51
+ return {
52
+ kind: 'setup',
53
+ path: selected.path,
54
+ status: { kind: 'setting-up', message: `Running setup for ${selected.branch}...` },
55
+ };
56
+ }
57
+ export function shouldApplyAsyncResult(state) {
58
+ return state.blocksInput || (state.generation === state.currentGeneration && !state.userActionInFlight);
59
+ }
@@ -0,0 +1,76 @@
1
+ import type { AppRow, RowTag } from './runtime.js';
2
+ export type ProjectionSeverity = 'success' | 'error' | 'info';
3
+ export type ProjectionTag = RowTag | string;
4
+ export type WorktreeListRowState = 'active' | 'invalid' | 'external' | 'normal';
5
+ export interface WorktreeListRowProjection {
6
+ state: WorktreeListRowState;
7
+ isSelected: boolean;
8
+ isMain: boolean;
9
+ }
10
+ export interface TagProjection {
11
+ tag: ProjectionTag;
12
+ }
13
+ export type ActionProjection = {
14
+ kind: 'blocked';
15
+ severity: 'error';
16
+ } | {
17
+ kind: 'active';
18
+ severity: 'success';
19
+ } | {
20
+ kind: 'startable';
21
+ severity: 'error' | 'info';
22
+ };
23
+ export type NoteProjection = {
24
+ kind: 'invalid';
25
+ severity: 'error';
26
+ invalidReason: string;
27
+ } | {
28
+ kind: 'external';
29
+ severity: 'info';
30
+ } | {
31
+ kind: 'ready';
32
+ severity: 'error' | 'info';
33
+ };
34
+ export type UpstreamProjection = {
35
+ kind: 'unavailable';
36
+ } | {
37
+ kind: 'none';
38
+ } | {
39
+ kind: 'found';
40
+ branch: string;
41
+ ahead: number;
42
+ behind: number;
43
+ };
44
+ export type WorkingTreePartKind = 'staged' | 'unstaged' | 'untracked' | 'conflicts';
45
+ export type WorkingTreeProjection = {
46
+ kind: 'unavailable';
47
+ } | {
48
+ kind: 'clean';
49
+ } | {
50
+ kind: 'dirty';
51
+ parts: Array<{
52
+ kind: WorkingTreePartKind;
53
+ count: number;
54
+ }>;
55
+ };
56
+ export type PullRequestProjection = {
57
+ kind: 'none';
58
+ } | {
59
+ kind: 'unavailable';
60
+ } | {
61
+ kind: 'found';
62
+ number: number;
63
+ title: string;
64
+ state: 'OPEN' | 'CLOSED' | 'MERGED';
65
+ isDraft: boolean;
66
+ baseBranch: string;
67
+ isHistorical: boolean;
68
+ };
69
+ export declare function sanitizeInlineText(value: string): string;
70
+ export declare function projectWorktreeListRow(row: AppRow, isSelected: boolean): WorktreeListRowProjection;
71
+ export declare function getOrderedNonActiveTags(tags: readonly string[]): TagProjection[];
72
+ export declare function projectAction(row: AppRow, activePath: string | null): ActionProjection;
73
+ export declare function projectNote(row: AppRow): NoteProjection;
74
+ export declare function projectUpstream(row: AppRow): UpstreamProjection;
75
+ export declare function projectWorkingTree(row: AppRow): WorkingTreeProjection;
76
+ export declare function projectPullRequest(row: AppRow): PullRequestProjection;