@ohzw/worktree-command-tui 0.1.5 → 0.1.6

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/app.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AppActions, AppModel } from './core/runtime.js';
1
+ import type { AppActions, AppLogRefresh, AppModel } from './core/runtime.js';
2
2
  export interface ShellDimensions {
3
3
  rootWidth: number;
4
4
  rootHeight: number;
@@ -14,9 +14,12 @@ export declare function getMouseWheelDelta(input: string): number;
14
14
  export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
15
15
  export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
16
16
  export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
17
+ export declare const RESIZE_DEBOUNCE_MS = 100;
17
18
  export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
18
- export declare function App({ initialModel, actions, windowSizeOverride, }: {
19
+ export declare function getModelAfterLogRefresh(current: AppModel, refresh: AppLogRefresh): AppModel;
20
+ export declare function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }: {
19
21
  initialModel: AppModel;
20
22
  actions: AppActions;
21
23
  windowSizeOverride?: AppWindowSize;
24
+ resizeDebounceMs?: number;
22
25
  }): import("react").JSX.Element;
package/dist/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Alert, Spinner } from '@inkjs/ui';
3
- import { useEffect, useMemo, useRef, useState } from 'react';
4
- import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from 'ink';
3
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
5
5
  import { ActionPanel } from './components/ActionPanel.js';
6
6
  import { ContextBar } from './components/ContextBar.js';
7
7
  import { Header } from './components/Header.js';
@@ -9,7 +9,7 @@ import { HelpWindow } from './components/HelpWindow.js';
9
9
  import { FloatingLogWindow } from './components/FloatingLogWindow.js';
10
10
  import { LogPanel, buildLogLines } from './components/LogPanel.js';
11
11
  import { WorktreeList } from './components/WorktreeList.js';
12
- import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
12
+ import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex, wrapSelectionIndex } from './core/tui-interaction.js';
13
13
  import { sanitizeInlineText } from './core/worktree-projection.js';
14
14
  const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
15
15
  const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
@@ -55,6 +55,7 @@ const HEADER_HEIGHT = 5;
55
55
  const CONTEXT_BAR_HEIGHT = 4;
56
56
  const PANE_GAP_WIDTH = 1;
57
57
  const MIN_STACKED_PANE_HEIGHT = 9;
58
+ export const RESIZE_DEBOUNCE_MS = 100;
58
59
  export function shouldStackPanes(columns, rows, _worktreeCount = 0) {
59
60
  // Header + context bar consume the fixed chrome; each stacked pane still keeps ~6 visible content lines at the minimum height.
60
61
  return columns < 96 && rows >= STACKED_LAYOUT_FRAME_ROWS + (MIN_STACKED_PANE_HEIGHT * 2);
@@ -86,9 +87,11 @@ function rowMatchesFilter(row, normalizedQuery) {
86
87
  return true;
87
88
  }
88
89
  const pullRequest = row.pullRequest?.kind === 'found' ? row.pullRequest : null;
90
+ const headCommit = `${row.headSha ?? ''} ${row.headCommit?.message ?? ''}`.toLowerCase();
89
91
  return row.branch.toLowerCase().includes(normalizedQuery)
90
92
  || row.path.toLowerCase().includes(normalizedQuery)
91
93
  || row.shortPath.toLowerCase().includes(normalizedQuery)
94
+ || headCommit.includes(normalizedQuery)
92
95
  || (pullRequest !== null && (`${pullRequest.number} ${pullRequest.title}`).toLowerCase().includes(normalizedQuery));
93
96
  }
94
97
  function filterRows(rows, query) {
@@ -116,12 +119,99 @@ function getStatusAfterLogRefresh(current, refresh) {
116
119
  }
117
120
  return { kind: 'running', message: 'running' };
118
121
  }
119
- export function App({ initialModel, actions, windowSizeOverride, }) {
122
+ function areLogsEqual(left, right) {
123
+ if (left.length !== right.length) {
124
+ return false;
125
+ }
126
+ return left.every((entry, index) => {
127
+ const other = right[index];
128
+ return other !== undefined
129
+ && entry.name === other.name
130
+ && entry.path === other.path
131
+ && entry.content === other.content;
132
+ });
133
+ }
134
+ export function getModelAfterLogRefresh(current, refresh) {
135
+ const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
136
+ const status = getStatusAfterLogRefresh(current, refresh);
137
+ const logsChanged = !areLogsEqual(current.logs, refresh.logs);
138
+ const statusChanged = current.status.kind !== status.kind || current.status.message !== status.message;
139
+ const rows = activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows;
140
+ if (!activeChanged && !logsChanged && !statusChanged) {
141
+ return current;
142
+ }
143
+ return {
144
+ ...current,
145
+ logs: refresh.logs,
146
+ activePath: refresh.activePath,
147
+ activeBranch: refresh.activeBranch,
148
+ status,
149
+ rows,
150
+ };
151
+ }
152
+ function readWindowSize(stdout) {
153
+ return {
154
+ columns: stdout.columns || process.stdout.columns || 80,
155
+ rows: stdout.rows || process.stdout.rows || 24,
156
+ };
157
+ }
158
+ function useDebouncedWindowSize(windowSizeOverride, debounceMs) {
159
+ const { stdout } = useStdout();
160
+ const resizeTimerRef = useRef(undefined);
161
+ const [stableWindowSize, setStableWindowSize] = useState(() => windowSizeOverride ?? readWindowSize(stdout));
162
+ useEffect(() => {
163
+ return () => {
164
+ clearTimeout(resizeTimerRef.current);
165
+ };
166
+ }, []);
167
+ useEffect(() => {
168
+ if (windowSizeOverride === undefined) {
169
+ return;
170
+ }
171
+ clearTimeout(resizeTimerRef.current);
172
+ if (stableWindowSize.columns === windowSizeOverride.columns && stableWindowSize.rows === windowSizeOverride.rows) {
173
+ return;
174
+ }
175
+ resizeTimerRef.current = setTimeout(() => {
176
+ resizeTimerRef.current = undefined;
177
+ setStableWindowSize({ columns: windowSizeOverride.columns, rows: windowSizeOverride.rows });
178
+ }, debounceMs);
179
+ }, [windowSizeOverride?.columns, windowSizeOverride?.rows, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
180
+ useEffect(() => {
181
+ if (windowSizeOverride !== undefined) {
182
+ return;
183
+ }
184
+ const scheduleResize = () => {
185
+ const nextWindowSize = readWindowSize(stdout);
186
+ clearTimeout(resizeTimerRef.current);
187
+ if (stableWindowSize.columns === nextWindowSize.columns && stableWindowSize.rows === nextWindowSize.rows) {
188
+ return;
189
+ }
190
+ resizeTimerRef.current = setTimeout(() => {
191
+ resizeTimerRef.current = undefined;
192
+ setStableWindowSize(nextWindowSize);
193
+ }, debounceMs);
194
+ };
195
+ scheduleResize();
196
+ stdout.on('resize', scheduleResize);
197
+ return () => {
198
+ stdout.off('resize', scheduleResize);
199
+ };
200
+ }, [stdout, windowSizeOverride, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
201
+ return stableWindowSize;
202
+ }
203
+ export function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }) {
204
+ const stableWindowSize = useDebouncedWindowSize(windowSizeOverride, resizeDebounceMs ?? RESIZE_DEBOUNCE_MS);
205
+ return _jsx(MemoizedAppShell, { initialModel: initialModel, actions: actions, windowSize: stableWindowSize });
206
+ }
207
+ function AppShell(props) {
208
+ return _jsx(AppShellContent, { ...props });
209
+ }
210
+ function AppShellContent({ initialModel, actions, windowSize, }) {
120
211
  const { exit } = useApp();
121
212
  const { stdin } = useStdin();
122
213
  const { stdout } = useStdout();
123
- const liveWindowSize = useWindowSize();
124
- const { columns, rows } = windowSizeOverride ?? liveWindowSize;
214
+ const { columns, rows } = windowSize;
125
215
  const [model, setModel] = useState(initialModel);
126
216
  const [filterQuery, setFilterQuery] = useState('');
127
217
  const [isFilterInputOpen, setIsFilterInputOpen] = useState(false);
@@ -189,18 +279,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
189
279
  if (generation !== actionGenerationRef.current || userActionInFlightRef.current) {
190
280
  return;
191
281
  }
192
- setModel(current => {
193
- const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
194
- const status = getStatusAfterLogRefresh(current, refresh);
195
- return {
196
- ...current,
197
- logs: refresh.logs,
198
- activePath: refresh.activePath,
199
- activeBranch: refresh.activeBranch,
200
- status,
201
- rows: activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows,
202
- };
203
- });
282
+ setModel(current => getModelAfterLogRefresh(current, refresh));
204
283
  })
205
284
  .catch(() => { })
206
285
  .finally(() => {
@@ -236,12 +315,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
236
315
  : showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
237
316
  const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
238
317
  const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
239
- function moveSelection(nextIndex) {
240
- const clampedIndex = clampSelectionIndex(nextIndex, visibleRows.length);
241
- if (clampedIndex === null) {
318
+ function moveSelection(nextIndex, mode = 'clamp') {
319
+ const nextSelectionIndex = mode === 'wrap'
320
+ ? wrapSelectionIndex(nextIndex, visibleRows.length)
321
+ : clampSelectionIndex(nextIndex, visibleRows.length);
322
+ if (nextSelectionIndex === null) {
242
323
  return;
243
324
  }
244
- setSelectedPath(visibleRows[clampedIndex].path);
325
+ setSelectedPath(visibleRows[nextSelectionIndex].path);
245
326
  }
246
327
  function clearFilter() {
247
328
  setFilterQuery('');
@@ -371,11 +452,11 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
371
452
  return;
372
453
  }
373
454
  if (key.upArrow) {
374
- moveSelection(selectedIndex - 1);
455
+ moveSelection(selectedIndex - 1, 'wrap');
375
456
  return;
376
457
  }
377
458
  if (key.downArrow) {
378
- moveSelection(selectedIndex + 1);
459
+ moveSelection(selectedIndex + 1, 'wrap');
379
460
  return;
380
461
  }
381
462
  if (key.pageDown) {
@@ -408,11 +489,11 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
408
489
  return;
409
490
  }
410
491
  if (key.upArrow || input === 'k') {
411
- moveSelection(selectedIndex - 1);
492
+ moveSelection(selectedIndex - 1, 'wrap');
412
493
  return;
413
494
  }
414
495
  if (key.downArrow || input === 'j') {
415
- moveSelection(selectedIndex + 1);
496
+ moveSelection(selectedIndex + 1, 'wrap');
416
497
  return;
417
498
  }
418
499
  if (input === 'g') {
@@ -633,3 +714,4 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
633
714
  }
634
715
  return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: visibleRows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset, filterQuery: filterQuery, isFilterInputOpen: isFilterInputOpen, totalRowCount: model.rows.length }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }), safeCompletedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: safeCompletedAlert }) })) : null] }));
635
716
  }
717
+ const MemoizedAppShell = memo(AppShell);
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { getOrderedNonActiveTags, projectAction, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
3
+ import { getOrderedNonActiveTags, projectAction, projectHeadCommit, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
4
4
  import { getScrollbarThumbRows, sliceListViewport } from '../terminal/viewport.js';
5
5
  export function getActionVariant(selectedRow, activePath) {
6
6
  return projectAction(selectedRow, activePath).severity;
@@ -72,6 +72,13 @@ function formatPullRequest(selectedRow) {
72
72
  const stateText = pullRequest.state.toLowerCase();
73
73
  return `#${pullRequest.number} ${draft}${stateText} → ${pullRequest.baseBranch}`;
74
74
  }
75
+ function formatHeadCommit(selectedRow) {
76
+ const headCommit = projectHeadCommit(selectedRow);
77
+ if (headCommit.kind === 'unavailable') {
78
+ return '-';
79
+ }
80
+ return headCommit.label;
81
+ }
75
82
  function getPullRequestColorFromProjection(pullRequest) {
76
83
  if (pullRequest.kind === 'unavailable') {
77
84
  return 'red';
@@ -150,7 +157,7 @@ function getPanelLines(selectedRow, activePath, setupAvailable, compactDetails)
150
157
  if (showFullPath) {
151
158
  lines.push({ text: `Full Path: ${sanitizeInlineText(selectedRow.path)}` });
152
159
  }
153
- lines.push({ text: `HEAD: ${selectedRow.headSha || '-'}` });
160
+ lines.push({ text: `HEAD: ${formatHeadCommit(selectedRow)}` });
154
161
  lines.push({ text: `Branch Created: ${formatUtcDateTime(selectedRow.branchCreatedAtMs)}` });
155
162
  if (showTags) {
156
163
  for (const { tag } of getOrderedNonActiveTags(selectedRow.tags)) {
@@ -0,0 +1,7 @@
1
+ type ResizeListener = (...args: unknown[]) => void;
2
+ interface ResizeEventSource {
3
+ on(event: string | symbol, listener: ResizeListener): ResizeEventSource;
4
+ off(event: string | symbol, listener: ResizeListener): ResizeEventSource;
5
+ }
6
+ export declare function debounceResizeListeners(source: ResizeEventSource, debounceMs: number): () => void;
7
+ export {};
@@ -0,0 +1,39 @@
1
+ export function debounceResizeListeners(source, debounceMs) {
2
+ const originalOn = source.on.bind(source);
3
+ const originalOff = source.off.bind(source);
4
+ const resizeListeners = new Set();
5
+ let resizeTimer;
6
+ let latestArgs = [];
7
+ const emitDebouncedResize = (...args) => {
8
+ latestArgs = args;
9
+ clearTimeout(resizeTimer);
10
+ resizeTimer = setTimeout(() => {
11
+ resizeTimer = undefined;
12
+ const listeners = [...resizeListeners];
13
+ for (const listener of listeners) {
14
+ listener(...latestArgs);
15
+ }
16
+ }, debounceMs);
17
+ };
18
+ originalOn('resize', emitDebouncedResize);
19
+ source.on = ((event, listener) => {
20
+ if (event === 'resize') {
21
+ resizeListeners.add(listener);
22
+ return source;
23
+ }
24
+ return originalOn(event, listener);
25
+ });
26
+ source.off = ((event, listener) => {
27
+ if (event === 'resize') {
28
+ resizeListeners.delete(listener);
29
+ return source;
30
+ }
31
+ return originalOff(event, listener);
32
+ });
33
+ return () => {
34
+ clearTimeout(resizeTimer);
35
+ originalOff('resize', emitDebouncedResize);
36
+ source.on = originalOn;
37
+ source.off = originalOff;
38
+ };
39
+ }
@@ -14,6 +14,9 @@ export interface GitStatusSummary {
14
14
  upstreamUnavailable: boolean;
15
15
  workingTree?: WorkingTreeInfo;
16
16
  }
17
+ export interface HeadCommitInfo {
18
+ message: string;
19
+ }
17
20
  export interface RepoContext {
18
21
  workspaceRoot: string;
19
22
  mainWorktreePath: string;
@@ -22,4 +25,5 @@ export interface RepoContext {
22
25
  export declare function parseGitStatusSummary(output: string): GitStatusSummary;
23
26
  export declare function readGitStatusSummary(cwd: string): Promise<GitStatusSummary>;
24
27
  export declare function readBranchCreatedAtMs(cwd: string, branch: string): Promise<number | null>;
28
+ export declare function readHeadCommitInfo(cwd: string): Promise<HeadCommitInfo | null>;
25
29
  export declare function resolveRepoContext(cwd: string): Promise<RepoContext>;
@@ -72,6 +72,15 @@ export async function readBranchCreatedAtMs(cwd, branch) {
72
72
  return null;
73
73
  }
74
74
  }
75
+ export async function readHeadCommitInfo(cwd) {
76
+ try {
77
+ const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%s'], { cwd });
78
+ return { message: stdout.trim() };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
75
84
  export async function resolveRepoContext(cwd) {
76
85
  const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
77
86
  execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
@@ -1,5 +1,5 @@
1
1
  import { type WorktreeRow } from './git-worktrees.js';
2
- import { type UpstreamInfo, type WorkingTreeInfo } from './git-metadata.js';
2
+ import { type UpstreamInfo, type WorkingTreeInfo, type HeadCommitInfo } from './git-metadata.js';
3
3
  import { type PullRequestInfo } from './github-metadata.js';
4
4
  import { type LogEntry } from './log-reader.js';
5
5
  export type RowTag = 'main' | 'active' | 'invalid' | 'external' | 'legacy';
@@ -8,6 +8,7 @@ export interface AppRow {
8
8
  shortPath: string;
9
9
  branch: string;
10
10
  headSha?: string;
11
+ headCommit?: HeadCommitInfo;
11
12
  tags: RowTag[];
12
13
  upstream?: UpstreamInfo;
13
14
  upstreamUnavailable?: boolean;
@@ -47,6 +48,6 @@ export interface AppActions {
47
48
  openPullRequest: (worktreePath: string) => Promise<AppModel>;
48
49
  deleteWorktree: (worktreePath: string) => Promise<AppModel>;
49
50
  }
50
- export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest' | 'branchCreatedAtMs'>): AppRow;
51
+ export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest' | 'branchCreatedAtMs' | 'headCommit'>): AppRow;
51
52
  export declare function buildInitialModel(cwd: string): Promise<AppModel>;
52
53
  export declare function buildActions(cwd: string): Promise<AppActions>;
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { promisify } from 'node:util';
4
4
  import { loadToolConfig } from './config-lifecycle.js';
5
5
  import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
6
- import { readGitStatusSummary, readBranchCreatedAtMs, resolveRepoContext } from './git-metadata.js';
6
+ import { readGitStatusSummary, readBranchCreatedAtMs, readHeadCommitInfo, resolveRepoContext } from './git-metadata.js';
7
7
  import { readPullRequestInfo } from './github-metadata.js';
8
8
  import { readLogs } from './log-reader.js';
9
9
  import { getInvalidReason } from './validation.js';
@@ -18,10 +18,11 @@ function shortenSha(headSha) {
18
18
  return headSha.slice(0, SHORT_SHA_LENGTH);
19
19
  }
20
20
  async function readRowMetadata(worktreePath, branch) {
21
- const [statusSummary, pullRequest, branchCreatedAtMs] = await Promise.all([
21
+ const [statusSummary, pullRequest, branchCreatedAtMs, headCommit] = await Promise.all([
22
22
  readGitStatusSummary(worktreePath),
23
23
  readPullRequestInfo(worktreePath, branch),
24
24
  readBranchCreatedAtMs(worktreePath, branch),
25
+ readHeadCommitInfo(worktreePath),
25
26
  ]);
26
27
  return {
27
28
  upstream: statusSummary.upstream,
@@ -29,6 +30,7 @@ async function readRowMetadata(worktreePath, branch) {
29
30
  workingTree: statusSummary.workingTree,
30
31
  pullRequest,
31
32
  branchCreatedAtMs: branchCreatedAtMs ?? undefined,
33
+ headCommit: headCommit ?? undefined,
32
34
  };
33
35
  }
34
36
  export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason, metadata) {
@@ -56,6 +58,7 @@ export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason,
56
58
  workingTree: metadata.workingTree,
57
59
  pullRequest: metadata.pullRequest,
58
60
  branchCreatedAtMs: metadata.branchCreatedAtMs,
61
+ headCommit: metadata.headCommit,
59
62
  invalidReason: invalidReason ?? undefined,
60
63
  };
61
64
  }
@@ -26,6 +26,7 @@ export interface AsyncInteractionState {
26
26
  export declare function getNextSelectedPath(rows: readonly AppRow[], currentPath: string | null): string | null;
27
27
  export declare function getSelectedIndex(rows: readonly AppRow[], selectedPath: string | null): number;
28
28
  export declare function clampSelectionIndex(nextIndex: number, rowCount: number): number | null;
29
+ export declare function wrapSelectionIndex(nextIndex: number, rowCount: number): number | null;
29
30
  export declare function decideEnterInteraction(selected: AppRow | undefined, activePath: string | null): EnterInteractionDecision;
30
31
  export declare function decideSetupInteraction(selected: AppRow | undefined, setupAvailable: boolean): SetupInteractionDecision;
31
32
  export declare function shouldApplyAsyncResult(state: AsyncInteractionState): boolean;
@@ -20,6 +20,12 @@ export function clampSelectionIndex(nextIndex, rowCount) {
20
20
  }
21
21
  return Math.min(Math.max(nextIndex, 0), rowCount - 1);
22
22
  }
23
+ export function wrapSelectionIndex(nextIndex, rowCount) {
24
+ if (rowCount <= 0) {
25
+ return null;
26
+ }
27
+ return ((nextIndex % rowCount) + rowCount) % rowCount;
28
+ }
23
29
  export function decideEnterInteraction(selected, activePath) {
24
30
  if (selected === undefined) {
25
31
  return { kind: 'ignore' };
@@ -41,6 +41,14 @@ export type UpstreamProjection = {
41
41
  ahead: number;
42
42
  behind: number;
43
43
  };
44
+ export type HeadCommitProjection = {
45
+ kind: 'unavailable';
46
+ } | {
47
+ kind: 'found';
48
+ hash?: string;
49
+ message?: string;
50
+ label: string;
51
+ };
44
52
  export type WorkingTreePartKind = 'staged' | 'unstaged' | 'untracked' | 'conflicts';
45
53
  export type WorkingTreeProjection = {
46
54
  kind: 'unavailable';
@@ -68,6 +76,7 @@ export type PullRequestProjection = {
68
76
  };
69
77
  export declare function sanitizeInlineText(value: string): string;
70
78
  export declare function projectWorktreeListRow(row: AppRow, isSelected: boolean): WorktreeListRowProjection;
79
+ export declare function projectHeadCommit(row: AppRow): HeadCommitProjection;
71
80
  export declare function getOrderedNonActiveTags(tags: readonly string[]): TagProjection[];
72
81
  export declare function projectAction(row: AppRow, activePath: string | null): ActionProjection;
73
82
  export declare function projectNote(row: AppRow): NoteProjection;
@@ -43,6 +43,19 @@ export function projectWorktreeListRow(row, isSelected) {
43
43
  isMain: hasTag(row, 'main'),
44
44
  };
45
45
  }
46
+ export function projectHeadCommit(row) {
47
+ const hash = sanitizeInlineText(row.headSha ?? '');
48
+ const message = sanitizeInlineText(row.headCommit?.message ?? '');
49
+ if (hash.length === 0 && message.length === 0) {
50
+ return { kind: 'unavailable' };
51
+ }
52
+ return {
53
+ kind: 'found',
54
+ hash: hash.length === 0 ? undefined : hash,
55
+ message: message.length === 0 ? undefined : message,
56
+ label: [hash, message].filter(part => part.length > 0).join(' '),
57
+ };
58
+ }
46
59
  export function getOrderedNonActiveTags(tags) {
47
60
  return tags
48
61
  .filter(tag => tag !== 'active')
package/dist/main.js CHANGED
@@ -6,8 +6,9 @@ import { sanitizeInlineText } from './core/worktree-projection.js';
6
6
  import { ThemeProvider } from '@inkjs/ui';
7
7
  import { render } from 'ink';
8
8
  import { APP_RENDER_OPTIONS } from './render-options.js';
9
- import { App } from './app.js';
9
+ import { App, RESIZE_DEBOUNCE_MS } from './app.js';
10
10
  import { buildActions, buildInitialModel } from './core/runtime.js';
11
+ import { debounceResizeListeners } from './core/debounced-resize.js';
11
12
  import { appTheme } from './ui-theme.js';
12
13
  const cwd = process.cwd();
13
14
  const args = process.argv.slice(2);
@@ -66,31 +67,22 @@ if (subcommand !== undefined) {
66
67
  console.error(`Unknown command: ${sanitizeInlineText(subcommand)}`);
67
68
  process.exit(1);
68
69
  }
70
+ let restoreStdoutResize = () => { };
69
71
  try {
70
72
  const [initialModel, actions] = await Promise.all([buildInitialModel(cwd), buildActions(cwd)]);
71
73
  const createApp = () => (_jsx(ThemeProvider, { theme: appTheme, children: _jsx(App, { initialModel: initialModel, actions: actions }) }));
74
+ if (process.stdout.isTTY) {
75
+ restoreStdoutResize = debounceResizeListeners(process.stdout, RESIZE_DEBOUNCE_MS);
76
+ }
72
77
  const instance = render(createApp(), APP_RENDER_OPTIONS);
73
- let repaintTimer;
74
- const repaintAfterResize = () => {
75
- // Give Ink/useWindowSize one tick to observe the new size, then force a fresh root render.
76
- if (repaintTimer) {
77
- clearTimeout(repaintTimer);
78
- }
79
- repaintTimer = setTimeout(() => {
80
- instance.rerender(createApp());
81
- }, 25);
82
- };
83
78
  if (process.stdout.isTTY) {
84
- process.stdout.on('resize', repaintAfterResize);
85
79
  void instance.waitUntilExit().finally(() => {
86
- if (repaintTimer) {
87
- clearTimeout(repaintTimer);
88
- }
89
- process.stdout.off('resize', repaintAfterResize);
80
+ restoreStdoutResize();
90
81
  });
91
82
  }
92
83
  }
93
84
  catch (error) {
85
+ restoreStdoutResize();
94
86
  console.error(sanitizeInlineText(describeError(error)));
95
87
  process.exit(1);
96
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohzw/worktree-command-tui",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A TUI for managing git worktrees",
5
5
  "private": false,
6
6
  "type": "module",