@camaradesuk/git-worktree-tools 1.0.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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/cli/cleanpr.d.ts +13 -0
  4. package/dist/cli/cleanpr.d.ts.map +1 -0
  5. package/dist/cli/cleanpr.js +441 -0
  6. package/dist/cli/cleanpr.js.map +1 -0
  7. package/dist/cli/lswt.d.ts +11 -0
  8. package/dist/cli/lswt.d.ts.map +1 -0
  9. package/dist/cli/lswt.js +313 -0
  10. package/dist/cli/lswt.js.map +1 -0
  11. package/dist/cli/newpr.d.ts +11 -0
  12. package/dist/cli/newpr.d.ts.map +1 -0
  13. package/dist/cli/newpr.js +888 -0
  14. package/dist/cli/newpr.js.map +1 -0
  15. package/dist/cli/wtlink.d.ts +15 -0
  16. package/dist/cli/wtlink.d.ts.map +1 -0
  17. package/dist/cli/wtlink.js +124 -0
  18. package/dist/cli/wtlink.js.map +1 -0
  19. package/dist/e2e/cli.e2e.test.d.ts +2 -0
  20. package/dist/e2e/cli.e2e.test.d.ts.map +1 -0
  21. package/dist/e2e/cli.e2e.test.js +215 -0
  22. package/dist/e2e/cli.e2e.test.js.map +1 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +17 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/integration/git.integration.test.d.ts +2 -0
  28. package/dist/integration/git.integration.test.d.ts.map +1 -0
  29. package/dist/integration/git.integration.test.js +333 -0
  30. package/dist/integration/git.integration.test.js.map +1 -0
  31. package/dist/lib/colors.d.ts +59 -0
  32. package/dist/lib/colors.d.ts.map +1 -0
  33. package/dist/lib/colors.js +145 -0
  34. package/dist/lib/colors.js.map +1 -0
  35. package/dist/lib/colors.test.d.ts +2 -0
  36. package/dist/lib/colors.test.d.ts.map +1 -0
  37. package/dist/lib/colors.test.js +69 -0
  38. package/dist/lib/colors.test.js.map +1 -0
  39. package/dist/lib/config.d.ts +58 -0
  40. package/dist/lib/config.d.ts.map +1 -0
  41. package/dist/lib/config.js +91 -0
  42. package/dist/lib/config.js.map +1 -0
  43. package/dist/lib/config.test.d.ts +2 -0
  44. package/dist/lib/config.test.d.ts.map +1 -0
  45. package/dist/lib/config.test.js +84 -0
  46. package/dist/lib/config.test.js.map +1 -0
  47. package/dist/lib/constants.d.ts +37 -0
  48. package/dist/lib/constants.d.ts.map +1 -0
  49. package/dist/lib/constants.js +37 -0
  50. package/dist/lib/constants.js.map +1 -0
  51. package/dist/lib/errors.d.ts +88 -0
  52. package/dist/lib/errors.d.ts.map +1 -0
  53. package/dist/lib/errors.js +112 -0
  54. package/dist/lib/errors.js.map +1 -0
  55. package/dist/lib/errors.test.d.ts +2 -0
  56. package/dist/lib/errors.test.d.ts.map +1 -0
  57. package/dist/lib/errors.test.js +117 -0
  58. package/dist/lib/errors.test.js.map +1 -0
  59. package/dist/lib/git.d.ts +224 -0
  60. package/dist/lib/git.d.ts.map +1 -0
  61. package/dist/lib/git.js +524 -0
  62. package/dist/lib/git.js.map +1 -0
  63. package/dist/lib/git.test.d.ts +2 -0
  64. package/dist/lib/git.test.d.ts.map +1 -0
  65. package/dist/lib/git.test.js +402 -0
  66. package/dist/lib/git.test.js.map +1 -0
  67. package/dist/lib/github.d.ts +82 -0
  68. package/dist/lib/github.d.ts.map +1 -0
  69. package/dist/lib/github.js +254 -0
  70. package/dist/lib/github.js.map +1 -0
  71. package/dist/lib/github.test.d.ts +2 -0
  72. package/dist/lib/github.test.d.ts.map +1 -0
  73. package/dist/lib/github.test.js +258 -0
  74. package/dist/lib/github.test.js.map +1 -0
  75. package/dist/lib/prompts.d.ts +39 -0
  76. package/dist/lib/prompts.d.ts.map +1 -0
  77. package/dist/lib/prompts.js +213 -0
  78. package/dist/lib/prompts.js.map +1 -0
  79. package/dist/lib/prompts.test.d.ts +2 -0
  80. package/dist/lib/prompts.test.d.ts.map +1 -0
  81. package/dist/lib/prompts.test.js +250 -0
  82. package/dist/lib/prompts.test.js.map +1 -0
  83. package/dist/lib/state-detection.d.ts +65 -0
  84. package/dist/lib/state-detection.d.ts.map +1 -0
  85. package/dist/lib/state-detection.js +186 -0
  86. package/dist/lib/state-detection.js.map +1 -0
  87. package/dist/lib/state-detection.test.d.ts +2 -0
  88. package/dist/lib/state-detection.test.d.ts.map +1 -0
  89. package/dist/lib/state-detection.test.js +164 -0
  90. package/dist/lib/state-detection.test.js.map +1 -0
  91. package/dist/lib/wtlink/index.d.ts +5 -0
  92. package/dist/lib/wtlink/index.d.ts.map +1 -0
  93. package/dist/lib/wtlink/index.js +7 -0
  94. package/dist/lib/wtlink/index.js.map +1 -0
  95. package/dist/lib/wtlink/link-configs.d.ts +10 -0
  96. package/dist/lib/wtlink/link-configs.d.ts.map +1 -0
  97. package/dist/lib/wtlink/link-configs.js +411 -0
  98. package/dist/lib/wtlink/link-configs.js.map +1 -0
  99. package/dist/lib/wtlink/link-configs.test.d.ts +2 -0
  100. package/dist/lib/wtlink/link-configs.test.d.ts.map +1 -0
  101. package/dist/lib/wtlink/link-configs.test.js +179 -0
  102. package/dist/lib/wtlink/link-configs.test.js.map +1 -0
  103. package/dist/lib/wtlink/main-menu.d.ts +2 -0
  104. package/dist/lib/wtlink/main-menu.d.ts.map +1 -0
  105. package/dist/lib/wtlink/main-menu.js +149 -0
  106. package/dist/lib/wtlink/main-menu.js.map +1 -0
  107. package/dist/lib/wtlink/manage-manifest.d.ts +9 -0
  108. package/dist/lib/wtlink/manage-manifest.d.ts.map +1 -0
  109. package/dist/lib/wtlink/manage-manifest.js +1262 -0
  110. package/dist/lib/wtlink/manage-manifest.js.map +1 -0
  111. package/dist/lib/wtlink/validate-manifest.d.ts +6 -0
  112. package/dist/lib/wtlink/validate-manifest.d.ts.map +1 -0
  113. package/dist/lib/wtlink/validate-manifest.js +51 -0
  114. package/dist/lib/wtlink/validate-manifest.js.map +1 -0
  115. package/dist/lib/wtlink/validate-manifest.test.d.ts +2 -0
  116. package/dist/lib/wtlink/validate-manifest.test.d.ts.map +1 -0
  117. package/dist/lib/wtlink/validate-manifest.test.js +115 -0
  118. package/dist/lib/wtlink/validate-manifest.test.js.map +1 -0
  119. package/package.json +84 -0
@@ -0,0 +1,1262 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execSync } from 'child_process';
5
+ import inquirer from 'inquirer';
6
+ import * as colors from '../colors.js';
7
+ import * as git from '../git.js';
8
+ import * as readline from 'readline';
9
+ import { signal, computed } from '@preact/signals-core';
10
+ // ============================================================================
11
+ // PURE FUNCTIONS - State computation and derivation
12
+ // ============================================================================
13
+ /**
14
+ * Get all file paths from a node (recursively)
15
+ */
16
+ function getAllFiles(node) {
17
+ if (!node.isDirectory) {
18
+ return [node.path];
19
+ }
20
+ return node.children.flatMap((child) => getAllFiles(child));
21
+ }
22
+ /**
23
+ * Get all directory nodes from a tree (recursively)
24
+ */
25
+ function getAllDirectories(node) {
26
+ if (!node.isDirectory) {
27
+ return [];
28
+ }
29
+ const directories = [];
30
+ for (const child of node.children) {
31
+ if (child.isDirectory) {
32
+ directories.push(child);
33
+ directories.push(...getAllDirectories(child));
34
+ }
35
+ }
36
+ return directories;
37
+ }
38
+ /**
39
+ * Get the computed state for a single file
40
+ * Returns 'undecided' (explicit state) if file not in decisions map
41
+ */
42
+ function getFileDecision(decisions, filePath) {
43
+ return decisions.get(filePath) ?? 'undecided';
44
+ }
45
+ /**
46
+ * Get all computed states present in a folder's descendants
47
+ * A folder can have multiple states if children have different states
48
+ * This is DERIVED from children - folders don't have their own decision
49
+ * 'undecided' is an explicit state for items with no decision
50
+ */
51
+ function getFolderStates(node, decisions) {
52
+ if (!node.isDirectory) {
53
+ return new Set([getFileDecision(decisions, node.path)]);
54
+ }
55
+ const states = new Set();
56
+ const allFiles = getAllFiles(node);
57
+ for (const filePath of allFiles) {
58
+ states.add(getFileDecision(decisions, filePath));
59
+ }
60
+ return states;
61
+ }
62
+ /**
63
+ * Get state breakdown counts for a folder
64
+ */
65
+ function getFolderStateBreakdown(node, decisions) {
66
+ const breakdown = { add: 0, comment: 0, skip: 0, undecided: 0 };
67
+ const allFiles = getAllFiles(node);
68
+ for (const filePath of allFiles) {
69
+ const decision = getFileDecision(decisions, filePath);
70
+ breakdown[decision]++;
71
+ }
72
+ return breakdown;
73
+ }
74
+ /**
75
+ * Check if an item should be visible based on current filters
76
+ * An item is visible if it has at least one state matching the active filters
77
+ * Default: only 'undecided' filter is active (shows undecided items)
78
+ */
79
+ function isItemVisible(item, activeFilters) {
80
+ // If no filters active, nothing is visible (edge case)
81
+ if (activeFilters.size === 0) {
82
+ return false;
83
+ }
84
+ // Item visible if it has any matching state
85
+ for (const filter of activeFilters) {
86
+ if (item.states.has(filter)) {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * Build file tree from flat list of file paths
94
+ */
95
+ function buildFileTree(filePaths) {
96
+ const root = {
97
+ path: '',
98
+ isDirectory: true,
99
+ children: [],
100
+ };
101
+ const nodeMap = new Map();
102
+ nodeMap.set('', root);
103
+ for (const filePath of filePaths) {
104
+ const parts = filePath.split('/');
105
+ let currentPath = '';
106
+ for (let i = 0; i < parts.length; i++) {
107
+ const part = parts[i];
108
+ const parentPath = currentPath;
109
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
110
+ const isLastPart = i === parts.length - 1;
111
+ if (!nodeMap.has(currentPath)) {
112
+ const node = {
113
+ path: currentPath,
114
+ isDirectory: !isLastPart,
115
+ children: isLastPart ? [] : [],
116
+ };
117
+ nodeMap.set(currentPath, node);
118
+ // Add to parent's children
119
+ const parent = nodeMap.get(parentPath);
120
+ parent.children.push(node);
121
+ }
122
+ }
123
+ }
124
+ // Make immutable
125
+ function makeImmutable(node) {
126
+ return {
127
+ path: node.path,
128
+ isDirectory: node.isDirectory,
129
+ children: node.children.map(makeImmutable),
130
+ };
131
+ }
132
+ return makeImmutable(root);
133
+ }
134
+ /**
135
+ * Find a node by path in the tree
136
+ */
137
+ function findNodeByPath(tree, targetPath) {
138
+ if (tree.path === targetPath) {
139
+ return tree;
140
+ }
141
+ for (const child of tree.children) {
142
+ const found = findNodeByPath(child, targetPath);
143
+ if (found)
144
+ return found;
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * Get items to display in hierarchical view
150
+ * Folders appear first, then files (both alphabetically sorted within their groups)
151
+ */
152
+ function getHierarchicalViewItems(tree, navigationStack, decisions) {
153
+ // Navigate to current directory
154
+ let currentNode = tree;
155
+ for (const dirPath of navigationStack) {
156
+ const found = findNodeByPath(currentNode, dirPath);
157
+ if (found) {
158
+ currentNode = found;
159
+ }
160
+ }
161
+ // Get children of current directory
162
+ const items = currentNode.children.map((child) => ({
163
+ path: child.path,
164
+ isDirectory: child.isDirectory,
165
+ node: child,
166
+ states: getFolderStates(child, decisions),
167
+ }));
168
+ // Separate folders and files
169
+ const folders = items.filter((item) => item.isDirectory);
170
+ const files = items.filter((item) => !item.isDirectory);
171
+ // Sort each group alphabetically by path
172
+ folders.sort((a, b) => a.path.localeCompare(b.path));
173
+ files.sort((a, b) => a.path.localeCompare(b.path));
174
+ // Return folders first, then files
175
+ return [...folders, ...files];
176
+ }
177
+ /**
178
+ * Get items to display in flat view
179
+ */
180
+ function getFlatViewItems(tree, decisions) {
181
+ const items = [];
182
+ // Add all directories
183
+ const allDirs = getAllDirectories(tree);
184
+ for (const dir of allDirs) {
185
+ items.push({
186
+ path: dir.path,
187
+ isDirectory: true,
188
+ node: dir,
189
+ states: getFolderStates(dir, decisions),
190
+ });
191
+ }
192
+ // Add all files
193
+ const allFiles = getAllFiles(tree);
194
+ for (const filePath of allFiles) {
195
+ const decision = getFileDecision(decisions, filePath);
196
+ items.push({
197
+ path: filePath,
198
+ isDirectory: false,
199
+ states: new Set([decision]),
200
+ });
201
+ }
202
+ // Sort alphabetically
203
+ return items.sort((a, b) => a.path.localeCompare(b.path));
204
+ }
205
+ /**
206
+ * Get all visible items for current state (SINGLE SOURCE OF TRUTH)
207
+ */
208
+ function getVisibleItems(state) {
209
+ // Get items based on view mode
210
+ const allItems = state.viewMode === 'flat'
211
+ ? getFlatViewItems(state.fileTree, state.decisions)
212
+ : getHierarchicalViewItems(state.fileTree, state.navigationStack, state.decisions);
213
+ // Filter based on active filters (no duplication possible)
214
+ return allItems.filter((item) => isItemVisible(item, state.activeFilters));
215
+ }
216
+ /**
217
+ * Get display items with back navigation if needed
218
+ */
219
+ function getDisplayItems(state) {
220
+ const items = getVisibleItems(state);
221
+ // Add back navigation in hierarchical view when in default undecided-only mode
222
+ const showingUndecidedOnly = state.activeFilters.size === 1 && state.activeFilters.has('undecided');
223
+ if (state.viewMode === 'hierarchical' &&
224
+ state.navigationStack.length > 0 &&
225
+ showingUndecidedOnly) {
226
+ return [
227
+ {
228
+ path: '..',
229
+ isDirectory: true,
230
+ states: new Set(),
231
+ },
232
+ ...items,
233
+ ];
234
+ }
235
+ return items;
236
+ }
237
+ // ============================================================================
238
+ // STATE UPDATES - Immutable state transitions
239
+ // ============================================================================
240
+ // These functions provide a functional API for state updates but are not currently used
241
+ // They're kept for potential future use or for testing purposes
242
+ function _updateDecision(state, item, decision) {
243
+ const newDecisions = new Map(state.decisions);
244
+ if (item.isDirectory && item.node) {
245
+ // Apply decision to all files in directory
246
+ const allFiles = getAllFiles(item.node);
247
+ for (const filePath of allFiles) {
248
+ newDecisions.set(filePath, decision);
249
+ }
250
+ }
251
+ else {
252
+ // Apply decision to single file
253
+ newDecisions.set(item.path, decision);
254
+ }
255
+ return { ...state, decisions: newDecisions };
256
+ }
257
+ function _toggleFilter(state, filter) {
258
+ const newFilters = new Set(state.activeFilters);
259
+ if (newFilters.has(filter)) {
260
+ newFilters.delete(filter);
261
+ }
262
+ else {
263
+ newFilters.add(filter);
264
+ }
265
+ return {
266
+ ...state,
267
+ activeFilters: newFilters,
268
+ cursorIndex: 0,
269
+ scrollOffset: 0,
270
+ };
271
+ }
272
+ function _toggleViewMode(state) {
273
+ return {
274
+ ...state,
275
+ viewMode: state.viewMode === 'flat' ? 'hierarchical' : 'flat',
276
+ navigationStack: [],
277
+ cursorIndex: 0,
278
+ scrollOffset: 0,
279
+ };
280
+ }
281
+ function _toggleHelp(state) {
282
+ return { ...state, showHelp: !state.showHelp };
283
+ }
284
+ function _moveCursor(state, delta) {
285
+ const displayItems = getDisplayItems(state);
286
+ const newIndex = Math.max(0, Math.min(displayItems.length - 1, state.cursorIndex + delta));
287
+ return { ...state, cursorIndex: newIndex };
288
+ }
289
+ function _navigateInto(state) {
290
+ if (state.viewMode === 'flat')
291
+ return state; // No navigation in flat view
292
+ const displayItems = getDisplayItems(state);
293
+ const selectedItem = displayItems[state.cursorIndex];
294
+ if (!selectedItem)
295
+ return state;
296
+ if (selectedItem.path === '..') {
297
+ // Go back
298
+ const newStack = state.navigationStack.slice(0, -1);
299
+ return {
300
+ ...state,
301
+ navigationStack: newStack,
302
+ cursorIndex: 0,
303
+ scrollOffset: 0,
304
+ };
305
+ }
306
+ if (selectedItem.isDirectory) {
307
+ // Navigate into any directory regardless of state
308
+ // Filtering will show what's visible based on active filters
309
+ return {
310
+ ...state,
311
+ navigationStack: [...state.navigationStack, selectedItem.path],
312
+ cursorIndex: 0,
313
+ scrollOffset: 0,
314
+ };
315
+ }
316
+ return state;
317
+ }
318
+ function _navigateBack(state) {
319
+ if (state.viewMode === 'flat' || state.navigationStack.length === 0) {
320
+ return state;
321
+ }
322
+ return {
323
+ ...state,
324
+ navigationStack: state.navigationStack.slice(0, -1),
325
+ cursorIndex: 0,
326
+ scrollOffset: 0,
327
+ };
328
+ }
329
+ function _updateScroll(state) {
330
+ const displayItems = getDisplayItems(state);
331
+ const terminalHeight = process.stdout.rows || 24;
332
+ const headerLines = 8;
333
+ const footerLines = 3;
334
+ const maxVisibleItems = Math.max(5, terminalHeight - headerLines - footerLines);
335
+ let { scrollOffset, cursorIndex } = state;
336
+ // Auto-scroll to keep cursor in view
337
+ if (cursorIndex < scrollOffset) {
338
+ scrollOffset = cursorIndex;
339
+ }
340
+ else if (cursorIndex >= scrollOffset + maxVisibleItems) {
341
+ scrollOffset = cursorIndex - maxVisibleItems + 1;
342
+ }
343
+ // Ensure scroll offset is valid
344
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, displayItems.length - maxVisibleItems)));
345
+ return { ...state, scrollOffset };
346
+ }
347
+ // ============================================================================
348
+ // COMMON DIRECTORIES
349
+ // ============================================================================
350
+ const COMMON_IGNORE_DIRS = [
351
+ 'node_modules',
352
+ 'bin',
353
+ 'obj',
354
+ '.git',
355
+ '.vs',
356
+ '.vscode',
357
+ '.idea',
358
+ 'dist',
359
+ 'build',
360
+ 'coverage',
361
+ '.next',
362
+ '.nuxt',
363
+ 'out',
364
+ 'target',
365
+ '__pycache__',
366
+ '.pytest_cache',
367
+ '.gradle',
368
+ 'vendor',
369
+ ];
370
+ function isCommonIgnoreDir(dirPath) {
371
+ const parts = dirPath.split('/');
372
+ return COMMON_IGNORE_DIRS.some((ignoreDir) => parts.includes(ignoreDir));
373
+ }
374
+ // ============================================================================
375
+ // RENDERING - Pure render functions
376
+ // ============================================================================
377
+ function renderStatusHeader(state, allFiles, gitRoot) {
378
+ const totalFiles = allFiles.length;
379
+ const decidedCount = state.decisions.size;
380
+ const remainingCount = totalFiles - decidedCount;
381
+ const addedCount = Array.from(state.decisions.values()).filter((d) => d === 'add').length;
382
+ const commentedCount = Array.from(state.decisions.values()).filter((d) => d === 'comment').length;
383
+ const skippedCount = Array.from(state.decisions.values()).filter((d) => d === 'skip').length;
384
+ const boxWidth = 110;
385
+ const horizontalLine = '═'.repeat(boxWidth - 2);
386
+ console.log(colors.bold(colors.cyan(`\n╔${horizontalLine}╗`)));
387
+ const title = 'Worktree Config Link Manager';
388
+ const titlePadding = Math.floor((boxWidth - 2 - title.length) / 2);
389
+ const titleLine = ' '.repeat(titlePadding) + title + ' '.repeat(boxWidth - 2 - titlePadding - title.length);
390
+ console.log(colors.bold(colors.cyan('║')) + colors.bold(titleLine) + colors.bold(colors.cyan('║')));
391
+ console.log(colors.bold(colors.cyan(`╚${horizontalLine}╝`)));
392
+ // Stats spread out horizontally with more spacing
393
+ console.log('');
394
+ const stats = [
395
+ colors.bold(colors.green('✓ Will Link: ')) + colors.green(addedCount.toString().padStart(4)),
396
+ colors.bold(colors.blue('◎ Tracked: ')) + colors.blue(commentedCount.toString().padStart(4)),
397
+ colors.bold(colors.yellow('✗ Skipped: ')) +
398
+ colors.bold(colors.yellow(skippedCount.toString().padStart(4))),
399
+ colors.bold('⋯ Undecided: ') + remainingCount.toString().padStart(4),
400
+ ];
401
+ console.log(' ' + stats.join(colors.dim(' │ ')));
402
+ // View status with better spacing and visual separator
403
+ const viewModes = [];
404
+ if (state.activeFilters.has('undecided'))
405
+ viewModes.push(colors.bold('Undecided'));
406
+ if (state.activeFilters.has('add'))
407
+ viewModes.push(colors.bold(colors.green('Added')));
408
+ if (state.activeFilters.has('comment'))
409
+ viewModes.push(colors.bold(colors.blue('Tracked')));
410
+ if (state.activeFilters.has('skip'))
411
+ viewModes.push(colors.bold(colors.yellow('Skipped')));
412
+ const viewModeStr = viewModes.length > 0 ? viewModes.join(colors.dim(', ')) : colors.dim('None (no items visible)');
413
+ const layoutMode = state.viewMode === 'flat' ? colors.cyan('Flat') : colors.cyan('Hierarchical');
414
+ console.log('');
415
+ console.log(colors.dim(' ┌─ ') + colors.bold('Viewing: ') + viewModeStr);
416
+ console.log(colors.dim(' ├─ ') + colors.bold('Layout: ') + layoutMode);
417
+ // Show full path: base (gitRoot) + navigated portion in bold
418
+ // Replace home directory with ~ for cleaner display
419
+ const homeDir = os.homedir();
420
+ let displayBasePath = gitRoot;
421
+ if (gitRoot.startsWith(homeDir)) {
422
+ displayBasePath = '~' + gitRoot.slice(homeDir.length);
423
+ }
424
+ // Normalize path separators to forward slashes for consistency
425
+ displayBasePath = displayBasePath.replace(/\\/g, '/');
426
+ const basePath = colors.dim(displayBasePath);
427
+ const navigatedPath = state.navigationStack.length > 0
428
+ ? colors.bold(colors.cyan('/' + state.navigationStack.join('/')))
429
+ : colors.dim('/');
430
+ console.log(colors.dim(' └─ ') + colors.bold('Path: ') + basePath + navigatedPath);
431
+ console.log('');
432
+ }
433
+ /**
434
+ * Render action hint for selected item (shown at top of file list)
435
+ * Always renders a fixed-height panel (2 lines) to prevent layout shifting
436
+ */
437
+ function renderActionHint(selectedItem, state) {
438
+ // Check if we should show info
439
+ let showInfo = false;
440
+ let infoText = '';
441
+ if (selectedItem && selectedItem.path !== '..' && selectedItem.isDirectory && selectedItem.node) {
442
+ if (selectedItem.states.has('undecided')) {
443
+ const breakdown = getFolderStateBreakdown(selectedItem.node, state.decisions);
444
+ if (breakdown.undecided > 0) {
445
+ const displayName = state.viewMode === 'hierarchical'
446
+ ? selectedItem.path.split('/').pop() || selectedItem.path
447
+ : selectedItem.path;
448
+ // Truncate long directory names to prevent wrapping (max 50 chars with more horizontal space)
449
+ const truncatedName = displayName.length > 50 ? displayName.substring(0, 47) + '...' : displayName;
450
+ const fileWord = breakdown.undecided === 1 ? 'file' : 'files';
451
+ // More visually appealing info panel with box drawing
452
+ infoText =
453
+ colors.dim(' ┌─ ') +
454
+ colors.bgYellow(colors.black(' ℹ ')) +
455
+ ' ' +
456
+ colors.bold(colors.yellow(`${truncatedName}`)) +
457
+ colors.dim(' │ ') +
458
+ colors.yellow(`${breakdown.undecided.toLocaleString()} undecided ${fileWord} inside`);
459
+ showInfo = true;
460
+ }
461
+ }
462
+ }
463
+ // Always render the info panel (2 lines total) to prevent shifting
464
+ if (showInfo) {
465
+ console.log(infoText);
466
+ console.log(colors.dim(' └' + '─'.repeat(108)));
467
+ }
468
+ else {
469
+ console.log(''); // Empty line to maintain spacing
470
+ console.log(''); // Empty line to maintain spacing
471
+ }
472
+ }
473
+ function renderHelp() {
474
+ console.log(colors.bold(colors.cyan('╔═══ HELP ════════════════════════════════════════════════════════════════════╗')));
475
+ console.log(colors.cyan('║') +
476
+ ' ' +
477
+ colors.bold('What do the actions do?') +
478
+ ' ' +
479
+ colors.cyan('║'));
480
+ console.log(colors.cyan('║') +
481
+ ' ' +
482
+ colors.cyan('║'));
483
+ console.log(colors.cyan('║') +
484
+ ' ' +
485
+ colors.bold(colors.green('A - Will Link')) +
486
+ ' ' +
487
+ colors.cyan('║'));
488
+ console.log(colors.cyan('║') +
489
+ ' → File will be ACTIVELY LINKED between worktrees ' +
490
+ colors.cyan('║'));
491
+ console.log(colors.cyan('║') +
492
+ ' → Added to manifest as: ' +
493
+ 'path/to/file.json' +
494
+ ' ' +
495
+ colors.cyan('║'));
496
+ console.log(colors.cyan('║') +
497
+ ' → Use for: config files you want to share (.vscode, .editorconfig) ' +
498
+ colors.cyan('║'));
499
+ console.log(colors.cyan('║') +
500
+ ' ' +
501
+ colors.cyan('║'));
502
+ console.log(colors.cyan('║') +
503
+ ' ' +
504
+ colors.bold(colors.blue('C - Will Track (Commented)')) +
505
+ ' ' +
506
+ colors.cyan('║'));
507
+ console.log(colors.cyan('║') +
508
+ " → File tracked in manifest but DISABLED (won't link) " +
509
+ colors.cyan('║'));
510
+ console.log(colors.cyan('║') +
511
+ ' → Added to manifest as: ' +
512
+ '# path/to/file.json' +
513
+ ' ' +
514
+ colors.cyan('║'));
515
+ console.log(colors.cyan('║') +
516
+ ' → Use for: files you might link later, or want to document ' +
517
+ colors.cyan('║'));
518
+ console.log(colors.cyan('║') +
519
+ ' ' +
520
+ colors.cyan('║'));
521
+ console.log(colors.cyan('║') +
522
+ ' ' +
523
+ colors.bold(colors.yellow("S - Won't Link")) +
524
+ ' ' +
525
+ colors.cyan('║'));
526
+ console.log(colors.cyan('║') +
527
+ ' → File completely IGNORED (not added to manifest) ' +
528
+ colors.cyan('║'));
529
+ console.log(colors.cyan('║') +
530
+ ' → Not in manifest at all ' +
531
+ colors.cyan('║'));
532
+ console.log(colors.cyan('║') +
533
+ ' → Use for: build artifacts (bin/, node_modules/), temp files ' +
534
+ colors.cyan('║'));
535
+ console.log(colors.cyan('║') +
536
+ ' ' +
537
+ colors.cyan('║'));
538
+ console.log(colors.cyan('║') +
539
+ ' ' +
540
+ colors.bold('View Toggles:') +
541
+ ' ' +
542
+ colors.cyan('║'));
543
+ console.log(colors.cyan('║') +
544
+ ' 0 = Toggle showing "Undecided" items (on by default) ' +
545
+ colors.cyan('║'));
546
+ console.log(colors.cyan('║') +
547
+ ' 1 = Toggle showing "Will Link" items ' +
548
+ colors.cyan('║'));
549
+ console.log(colors.cyan('║') +
550
+ ' 2 = Toggle showing "Will Track (Commented)" items ' +
551
+ colors.cyan('║'));
552
+ console.log(colors.cyan('║') +
553
+ ' 3 = Toggle showing "Won\'t Link" items ' +
554
+ colors.cyan('║'));
555
+ console.log(colors.cyan('║') +
556
+ ' V = Toggle flat vs hierarchical view ' +
557
+ colors.cyan('║'));
558
+ console.log(colors.cyan('║') +
559
+ ' ' +
560
+ colors.cyan('║'));
561
+ console.log(colors.cyan('║') +
562
+ ' ' +
563
+ colors.bold('Exit:') +
564
+ ' ' +
565
+ colors.cyan('║'));
566
+ console.log(colors.cyan('║') +
567
+ ' Q = Save changes and quit ' +
568
+ colors.cyan('║'));
569
+ console.log(colors.cyan('║') +
570
+ ' X = Cancel without saving (Ctrl+C also works) ' +
571
+ colors.cyan('║'));
572
+ console.log(colors.cyan('║') +
573
+ ' ' +
574
+ colors.cyan('║'));
575
+ console.log(colors.cyan('║') +
576
+ ' ' +
577
+ colors.bold('Folders:') +
578
+ ' ' +
579
+ colors.cyan('║'));
580
+ console.log(colors.cyan('║') +
581
+ ' Actions on folders apply to ALL files inside ' +
582
+ colors.cyan('║'));
583
+ console.log(colors.cyan('║') +
584
+ ' Folders show state breakdown of all descendants ' +
585
+ colors.cyan('║'));
586
+ console.log(colors.cyan('║') +
587
+ ' ' +
588
+ colors.cyan('║'));
589
+ console.log(colors.cyan('║') +
590
+ ' Press ' +
591
+ colors.bold('?') +
592
+ ' again to close help ' +
593
+ colors.cyan('║'));
594
+ console.log(colors.bold(colors.cyan('╚═════════════════════════════════════════════════════════════════════════════╝')));
595
+ }
596
+ function renderItem(item, isSelected, state) {
597
+ const cursor = isSelected ? colors.bgBlue(' ▶ ') : ' ';
598
+ if (item.path === '..') {
599
+ return cursor + ' ' + colors.dim('⬆️ .. (go back)');
600
+ }
601
+ const icon = item.isDirectory ? '📁' : '📄';
602
+ let applyColor = (s) => (item.isDirectory ? colors.cyan(s) : s);
603
+ // In hierarchical mode, show only the base name (not full path)
604
+ let displayName = item.path;
605
+ if (state.viewMode === 'hierarchical') {
606
+ const parts = item.path.split('/');
607
+ displayName = parts[parts.length - 1];
608
+ }
609
+ // Determine item color based on states
610
+ // If item has single decided state, use that color
611
+ const statesArray = Array.from(item.states);
612
+ if (statesArray.length === 1 && statesArray[0] !== 'undecided') {
613
+ const singleState = statesArray[0];
614
+ if (singleState === 'add')
615
+ applyColor = colors.green;
616
+ else if (singleState === 'comment')
617
+ applyColor = colors.blue;
618
+ else if (singleState === 'skip')
619
+ applyColor = colors.yellow;
620
+ }
621
+ // Status prefix - always same width for alignment
622
+ let statusPrefix = ' '; // Default: 2 spaces (icon + space)
623
+ if (statesArray.length === 1 && statesArray[0] !== 'undecided') {
624
+ const stateValue = statesArray[0];
625
+ if (stateValue === 'add')
626
+ statusPrefix = colors.green('✓ ');
627
+ else if (stateValue === 'comment')
628
+ statusPrefix = colors.blue('◎ ');
629
+ else if (stateValue === 'skip')
630
+ statusPrefix = colors.yellow('✗ ');
631
+ }
632
+ let label = `${cursor}${statusPrefix}${icon} ${applyColor(displayName)}`;
633
+ // Add folder breakdown
634
+ if (item.isDirectory && item.node) {
635
+ const breakdown = getFolderStateBreakdown(item.node, state.decisions);
636
+ // Build breakdown display
637
+ const breakdownParts = [];
638
+ if (breakdown.undecided > 0) {
639
+ breakdownParts.push(`${breakdown.undecided} undecided`);
640
+ }
641
+ if (breakdown.add > 0) {
642
+ breakdownParts.push(colors.green(`${breakdown.add} added`));
643
+ }
644
+ if (breakdown.comment > 0) {
645
+ breakdownParts.push(colors.blue(`${breakdown.comment} commented`));
646
+ }
647
+ if (breakdown.skip > 0) {
648
+ breakdownParts.push(colors.yellow(`${breakdown.skip} skipped`));
649
+ }
650
+ const totalFiles = getAllFiles(item.node).length;
651
+ // Show breakdown in a cleaner format
652
+ if (breakdownParts.length > 1) {
653
+ label += colors.dim(` (${breakdownParts.join(', ')})`);
654
+ }
655
+ else if (breakdownParts.length === 1) {
656
+ label += colors.dim(` (${totalFiles} file${totalFiles === 1 ? '' : 's'}: ${breakdownParts[0]})`);
657
+ }
658
+ // Show auto-ignore tag
659
+ if (isCommonIgnoreDir(item.path)) {
660
+ label += colors.dim(colors.yellow(' [auto-ignore]'));
661
+ }
662
+ }
663
+ return label;
664
+ }
665
+ function renderItems(state, cachedDisplayItems) {
666
+ const displayItems = cachedDisplayItems; // Use pre-computed cached items!
667
+ const terminalHeight = process.stdout.rows || 24;
668
+ const headerLines = 13; // Header (11 lines) + Action hint panel (2 lines)
669
+ const footerLines = 3;
670
+ const maxVisibleItems = Math.max(5, terminalHeight - headerLines - footerLines);
671
+ const visibleStart = state.scrollOffset;
672
+ const visibleEnd = Math.min(displayItems.length, state.scrollOffset + maxVisibleItems);
673
+ const hasMoreAbove = visibleStart > 0;
674
+ const hasMoreBelow = visibleEnd < displayItems.length;
675
+ if (hasMoreAbove) {
676
+ console.log(colors.dim(colors.cyan(` ↑ ${visibleStart} more items above...`)));
677
+ }
678
+ for (let i = visibleStart; i < visibleEnd; i++) {
679
+ const item = displayItems[i];
680
+ const isSelected = i === state.cursorIndex;
681
+ console.log(renderItem(item, isSelected, state));
682
+ }
683
+ if (hasMoreBelow) {
684
+ console.log(colors.dim(colors.cyan(` ↓ ${displayItems.length - visibleEnd} more items below...`)));
685
+ }
686
+ }
687
+ function renderFooter(state) {
688
+ console.log('\n' + colors.dim('─'.repeat(110)));
689
+ // Navigation - spread out with better formatting
690
+ const navParts = [];
691
+ navParts.push(colors.dim('↑↓') + ' Select');
692
+ if (state.viewMode === 'hierarchical') {
693
+ navParts.push(colors.dim('←') + ' Back');
694
+ navParts.push(colors.dim('→') + ' Drill In');
695
+ }
696
+ const navigation = colors.bold('Navigation: ') + navParts.join(colors.dim(' │ '));
697
+ // Actions - colorful and spaced out
698
+ const actions = colors.bold('Actions: ') +
699
+ colors.bold(colors.green('A')) +
700
+ ' Link' +
701
+ colors.dim(' │ ') +
702
+ colors.bold(colors.blue('C')) +
703
+ ' Comment' +
704
+ colors.dim(' │ ') +
705
+ colors.bold(colors.yellow('S')) +
706
+ ' Skip';
707
+ console.log(' ' + navigation + colors.dim(' ') + actions);
708
+ // Filters - show active state with better spacing
709
+ const filterParts = [];
710
+ filterParts.push((state.activeFilters.has('undecided') ? colors.bold('0') : colors.dim('0')) + ' Undecided');
711
+ filterParts.push((state.activeFilters.has('add') ? colors.bold(colors.green('1')) : colors.dim('1')) + ' Added');
712
+ filterParts.push((state.activeFilters.has('comment') ? colors.bold(colors.blue('2')) : colors.dim('2')) +
713
+ ' Tracked');
714
+ filterParts.push((state.activeFilters.has('skip') ? colors.bold(colors.yellow('3')) : colors.dim('3')) +
715
+ ' Skipped');
716
+ filterParts.push((state.viewMode === 'flat' ? colors.bold(colors.cyan('V')) : colors.dim('V')) + ' Flat');
717
+ const filters = colors.bold('View: ') + filterParts.join(colors.dim(' │ '));
718
+ // Controls - help and exit
719
+ const controls = colors.bold('Controls: ') +
720
+ colors.bold('?') +
721
+ ' Help' +
722
+ colors.dim(' │ ') +
723
+ colors.bold(colors.red('Q')) +
724
+ ' Save & Quit' +
725
+ colors.dim(' │ ') +
726
+ colors.bold(colors.red('X')) +
727
+ ' Cancel';
728
+ console.log(' ' + filters + colors.dim(' ') + controls);
729
+ }
730
+ function render(state, allFiles, gitRoot, cachedDisplayItems) {
731
+ console.clear();
732
+ if (state.showHelp) {
733
+ renderHelp();
734
+ return;
735
+ }
736
+ renderStatusHeader(state, allFiles, gitRoot);
737
+ const displayItems = cachedDisplayItems; // Use pre-computed cached items!
738
+ if (displayItems.length === 0) {
739
+ console.log(colors.green('\n ✓ All items processed!\n'));
740
+ return;
741
+ }
742
+ // Show action hint for selected item
743
+ const selectedItem = displayItems[state.cursorIndex];
744
+ renderActionHint(selectedItem, state);
745
+ renderItems(state, displayItems);
746
+ renderFooter(state);
747
+ }
748
+ // ============================================================================
749
+ // MAIN INTERACTIVE LOOP - Using Signals for Performance
750
+ // ============================================================================
751
+ async function interactiveManage(allFiles, gitRoot, initialDecisions) {
752
+ const fileTree = buildFileTree(allFiles);
753
+ // Reactive signals for mutable state
754
+ const decisions$ = signal(initialDecisions ? new Map(initialDecisions) : new Map());
755
+ const viewMode$ = signal('hierarchical');
756
+ const activeFilters$ = signal(new Set(['undecided'])); // Default: show undecided items
757
+ const showHelp$ = signal(false);
758
+ const navigationStack$ = signal([]);
759
+ const cursorIndex$ = signal(0);
760
+ const scrollOffset$ = signal(0);
761
+ // Computed signal for visible items (cached, only recomputes when dependencies change)
762
+ // IMPORTANT: Only read signals that getVisibleItems() actually uses!
763
+ const visibleItems$ = computed(() => {
764
+ const viewMode = viewMode$.value;
765
+ const decisions = decisions$.value;
766
+ const activeFilters = activeFilters$.value;
767
+ const navigationStack = navigationStack$.value;
768
+ // Build minimal state object with only what getVisibleItems needs
769
+ // Don't read cursor/scroll/help signals here - they would cause unnecessary recalculation!
770
+ const state = {
771
+ fileTree,
772
+ decisions,
773
+ viewMode,
774
+ activeFilters,
775
+ showHelp: false, // Not used by getVisibleItems
776
+ navigationStack,
777
+ cursorIndex: 0, // Not used by getVisibleItems
778
+ scrollOffset: 0, // Not used by getVisibleItems
779
+ };
780
+ return getVisibleItems(state);
781
+ });
782
+ // Computed signal for display items with back navigation
783
+ const displayItems$ = computed(() => {
784
+ const items = visibleItems$.value;
785
+ const viewMode = viewMode$.value;
786
+ const navigationStack = navigationStack$.value;
787
+ const activeFilters = activeFilters$.value;
788
+ // Add back navigation in hierarchical view when in default undecided-only mode
789
+ const showingUndecidedOnly = activeFilters.size === 1 && activeFilters.has('undecided');
790
+ if (viewMode === 'hierarchical' && navigationStack.length > 0 && showingUndecidedOnly) {
791
+ return [
792
+ {
793
+ path: '..',
794
+ isDirectory: true,
795
+ states: new Set(),
796
+ },
797
+ ...items,
798
+ ];
799
+ }
800
+ return items;
801
+ });
802
+ return new Promise((resolve) => {
803
+ readline.emitKeypressEvents(process.stdin);
804
+ if (process.stdin.isTTY) {
805
+ process.stdin.setRawMode(true);
806
+ }
807
+ const buildState = () => ({
808
+ fileTree,
809
+ decisions: decisions$.value,
810
+ viewMode: viewMode$.value,
811
+ activeFilters: activeFilters$.value,
812
+ showHelp: showHelp$.value,
813
+ navigationStack: navigationStack$.value,
814
+ cursorIndex: cursorIndex$.value,
815
+ scrollOffset: scrollOffset$.value,
816
+ });
817
+ const renderCurrent = () => {
818
+ // Use cached displayItems$ - NO recalculation on cursor movement!
819
+ const displayItems = displayItems$.value;
820
+ const terminalHeight = process.stdout.rows || 24;
821
+ const headerLines = 13; // Header (11 lines) + Action hint panel (2 lines)
822
+ const footerLines = 3;
823
+ const maxVisibleItems = Math.max(5, terminalHeight - headerLines - footerLines);
824
+ let scrollOffset = scrollOffset$.value;
825
+ const cursorIndex = cursorIndex$.value;
826
+ // Auto-scroll to keep cursor in view
827
+ if (cursorIndex < scrollOffset) {
828
+ scrollOffset = cursorIndex;
829
+ }
830
+ else if (cursorIndex >= scrollOffset + maxVisibleItems) {
831
+ scrollOffset = cursorIndex - maxVisibleItems + 1;
832
+ }
833
+ // Ensure scroll offset is valid
834
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, displayItems.length - maxVisibleItems)));
835
+ // Update scroll signals if changed
836
+ if (scrollOffset !== scrollOffset$.value) {
837
+ scrollOffset$.value = scrollOffset;
838
+ }
839
+ // Render with cached displayItems - NO recalculation!
840
+ render(buildState(), allFiles, gitRoot, displayItems);
841
+ };
842
+ const cleanup = () => {
843
+ if (process.stdin.isTTY) {
844
+ process.stdin.setRawMode(false);
845
+ }
846
+ process.stdin.removeListener('keypress', onKeypress);
847
+ };
848
+ const onKeypress = (str, key) => {
849
+ const displayItems = displayItems$.value; // Cached!
850
+ if (key.name === 'up') {
851
+ const newIndex = Math.max(0, cursorIndex$.value - 1);
852
+ cursorIndex$.value = newIndex;
853
+ renderCurrent();
854
+ }
855
+ else if (key.name === 'down') {
856
+ const newIndex = Math.min(displayItems.length - 1, cursorIndex$.value + 1);
857
+ cursorIndex$.value = newIndex;
858
+ renderCurrent();
859
+ }
860
+ else if (key.name === 'right') {
861
+ if (viewMode$.value === 'flat')
862
+ return;
863
+ const selectedItem = displayItems[cursorIndex$.value];
864
+ if (!selectedItem)
865
+ return;
866
+ if (selectedItem.path === '..') {
867
+ const stack = navigationStack$.value;
868
+ navigationStack$.value = stack.slice(0, -1);
869
+ cursorIndex$.value = 0;
870
+ scrollOffset$.value = 0;
871
+ }
872
+ else if (selectedItem.isDirectory) {
873
+ // Navigate into any directory regardless of state
874
+ navigationStack$.value = [...navigationStack$.value, selectedItem.path];
875
+ cursorIndex$.value = 0;
876
+ scrollOffset$.value = 0;
877
+ }
878
+ renderCurrent();
879
+ }
880
+ else if (key.name === 'left') {
881
+ if (viewMode$.value === 'flat' || navigationStack$.value.length === 0)
882
+ return;
883
+ const stack = navigationStack$.value;
884
+ navigationStack$.value = stack.slice(0, -1);
885
+ cursorIndex$.value = 0;
886
+ scrollOffset$.value = 0;
887
+ renderCurrent();
888
+ }
889
+ else if (str === 'a' || str === 'A') {
890
+ const selectedItem = displayItems[cursorIndex$.value];
891
+ if (selectedItem && selectedItem.path !== '..') {
892
+ const newDecisions = new Map(decisions$.value);
893
+ if (selectedItem.isDirectory && selectedItem.node) {
894
+ const allFilesInDir = getAllFiles(selectedItem.node);
895
+ for (const filePath of allFilesInDir) {
896
+ newDecisions.set(filePath, 'add');
897
+ }
898
+ }
899
+ else {
900
+ newDecisions.set(selectedItem.path, 'add');
901
+ }
902
+ decisions$.value = newDecisions;
903
+ renderCurrent();
904
+ }
905
+ }
906
+ else if (str === 'c' || str === 'C') {
907
+ const selectedItem = displayItems[cursorIndex$.value];
908
+ if (selectedItem && selectedItem.path !== '..') {
909
+ const newDecisions = new Map(decisions$.value);
910
+ if (selectedItem.isDirectory && selectedItem.node) {
911
+ const allFilesInDir = getAllFiles(selectedItem.node);
912
+ for (const filePath of allFilesInDir) {
913
+ newDecisions.set(filePath, 'comment');
914
+ }
915
+ }
916
+ else {
917
+ newDecisions.set(selectedItem.path, 'comment');
918
+ }
919
+ decisions$.value = newDecisions;
920
+ renderCurrent();
921
+ }
922
+ }
923
+ else if (str === 's' || str === 'S') {
924
+ const selectedItem = displayItems[cursorIndex$.value];
925
+ if (selectedItem && selectedItem.path !== '..') {
926
+ const newDecisions = new Map(decisions$.value);
927
+ if (selectedItem.isDirectory && selectedItem.node) {
928
+ const allFilesInDir = getAllFiles(selectedItem.node);
929
+ for (const filePath of allFilesInDir) {
930
+ newDecisions.set(filePath, 'skip');
931
+ }
932
+ }
933
+ else {
934
+ newDecisions.set(selectedItem.path, 'skip');
935
+ }
936
+ decisions$.value = newDecisions;
937
+ renderCurrent();
938
+ }
939
+ }
940
+ else if (str === 'q' || str === 'Q') {
941
+ console.clear();
942
+ console.log(colors.green('✓ Saving decisions...'));
943
+ cleanup();
944
+ resolve(decisions$.value);
945
+ }
946
+ else if (str === 'x' || str === 'X' || (key.ctrl && key.name === 'c')) {
947
+ console.clear();
948
+ console.log(colors.yellow('✗ Cancelled - no changes saved'));
949
+ cleanup();
950
+ resolve(new Map());
951
+ }
952
+ else if (str === '?') {
953
+ showHelp$.value = !showHelp$.value;
954
+ renderCurrent();
955
+ }
956
+ else if (str === '0') {
957
+ const newFilters = new Set(activeFilters$.value);
958
+ if (newFilters.has('undecided')) {
959
+ newFilters.delete('undecided');
960
+ }
961
+ else {
962
+ newFilters.add('undecided');
963
+ }
964
+ activeFilters$.value = newFilters;
965
+ cursorIndex$.value = 0;
966
+ scrollOffset$.value = 0;
967
+ renderCurrent();
968
+ }
969
+ else if (str === '1') {
970
+ const newFilters = new Set(activeFilters$.value);
971
+ if (newFilters.has('add')) {
972
+ newFilters.delete('add');
973
+ }
974
+ else {
975
+ newFilters.add('add');
976
+ }
977
+ activeFilters$.value = newFilters;
978
+ cursorIndex$.value = 0;
979
+ scrollOffset$.value = 0;
980
+ renderCurrent();
981
+ }
982
+ else if (str === '2') {
983
+ const newFilters = new Set(activeFilters$.value);
984
+ if (newFilters.has('comment')) {
985
+ newFilters.delete('comment');
986
+ }
987
+ else {
988
+ newFilters.add('comment');
989
+ }
990
+ activeFilters$.value = newFilters;
991
+ cursorIndex$.value = 0;
992
+ scrollOffset$.value = 0;
993
+ renderCurrent();
994
+ }
995
+ else if (str === '3') {
996
+ const newFilters = new Set(activeFilters$.value);
997
+ if (newFilters.has('skip')) {
998
+ newFilters.delete('skip');
999
+ }
1000
+ else {
1001
+ newFilters.add('skip');
1002
+ }
1003
+ activeFilters$.value = newFilters;
1004
+ cursorIndex$.value = 0;
1005
+ scrollOffset$.value = 0;
1006
+ renderCurrent();
1007
+ }
1008
+ else if (str === 'v' || str === 'V') {
1009
+ viewMode$.value = viewMode$.value === 'flat' ? 'hierarchical' : 'flat';
1010
+ navigationStack$.value = [];
1011
+ cursorIndex$.value = 0;
1012
+ scrollOffset$.value = 0;
1013
+ renderCurrent();
1014
+ }
1015
+ };
1016
+ process.stdin.on('keypress', onKeypress);
1017
+ renderCurrent();
1018
+ });
1019
+ }
1020
+ // ============================================================================
1021
+ // GIT OPERATIONS - Using central git.ts utilities
1022
+ // ============================================================================
1023
+ function checkGitInstalled() {
1024
+ if (!git.checkGitInstalled()) {
1025
+ throw new Error('Git is not installed or not found in your PATH. This tool requires Git.');
1026
+ }
1027
+ }
1028
+ function getGitRoot() {
1029
+ return git.getRepoRoot();
1030
+ }
1031
+ function getMainWorktreeRoot() {
1032
+ return git.getMainWorktreeRoot();
1033
+ }
1034
+ function isIgnored(filePath, gitRoot) {
1035
+ return git.isGitIgnored(filePath, gitRoot);
1036
+ }
1037
+ function getIgnoredFiles(gitRoot, manifestFile) {
1038
+ try {
1039
+ const ignored = execSync('git ls-files --ignored --exclude-standard --others', {
1040
+ cwd: gitRoot,
1041
+ maxBuffer: 50 * 1024 * 1024,
1042
+ })
1043
+ .toString()
1044
+ .trim();
1045
+ if (!ignored)
1046
+ return [];
1047
+ return ignored.split('\n').filter((f) => f && !f.includes(manifestFile));
1048
+ }
1049
+ catch {
1050
+ console.error(colors.yellow('Warning: Could not run `git ls-files`. This may be a new repository.'));
1051
+ return [];
1052
+ }
1053
+ }
1054
+ function getManifestEntries(gitRoot, manifestFile) {
1055
+ const manifestPath = path.join(gitRoot, manifestFile);
1056
+ if (!fs.existsSync(manifestPath))
1057
+ return [];
1058
+ return fs
1059
+ .readFileSync(manifestPath, 'utf-8')
1060
+ .split('\n')
1061
+ .filter((x) => x.trim() && !x.startsWith('#'));
1062
+ }
1063
+ function getManifestDecisions(gitRoot, manifestFile) {
1064
+ const manifestPath = path.join(gitRoot, manifestFile);
1065
+ const decisions = new Map();
1066
+ if (!fs.existsSync(manifestPath))
1067
+ return decisions;
1068
+ const lines = fs.readFileSync(manifestPath, 'utf-8').split('\n');
1069
+ for (const line of lines) {
1070
+ const trimmed = line.trim();
1071
+ if (!trimmed)
1072
+ continue;
1073
+ if (trimmed.startsWith('#')) {
1074
+ // Commented entry - extract file path
1075
+ // Format: "# path/to/file" or "# TRACKED: path/to/file" or "# DELETED: path/to/file"
1076
+ const commentContent = trimmed.substring(1).trim();
1077
+ let filePath = commentContent;
1078
+ // Strip prefixes like "TRACKED:", "DELETED:", "STALE:"
1079
+ const prefixMatch = commentContent.match(/^(TRACKED|DELETED|STALE):\s*(.+)/);
1080
+ if (prefixMatch) {
1081
+ filePath = prefixMatch[2];
1082
+ }
1083
+ decisions.set(filePath, 'comment');
1084
+ }
1085
+ else {
1086
+ // Active entry
1087
+ decisions.set(trimmed, 'add');
1088
+ }
1089
+ }
1090
+ return decisions;
1091
+ }
1092
+ // ============================================================================
1093
+ // MAIN EXPORT
1094
+ // ============================================================================
1095
+ export async function run(argv) {
1096
+ checkGitInstalled();
1097
+ const gitRoot = getGitRoot(); // Current worktree root (for finding files)
1098
+ const mainWorktreeRoot = getMainWorktreeRoot(); // Main worktree root (for manifest location)
1099
+ const manifestFile = argv.manifestFile;
1100
+ const manifestPath = path.join(mainWorktreeRoot, manifestFile);
1101
+ const manifestBackupFile = manifestPath + '.bak';
1102
+ const existingEntries = getManifestEntries(mainWorktreeRoot, manifestFile);
1103
+ const ignoredFiles = getIgnoredFiles(gitRoot, manifestFile);
1104
+ const newFiles = ignoredFiles.filter((f) => !existingEntries.includes(f));
1105
+ // Categorize stale entries
1106
+ const trackedEntries = [];
1107
+ const deletedEntries = [];
1108
+ for (const entry of existingEntries) {
1109
+ const fullPath = path.join(gitRoot, entry);
1110
+ if (!fs.existsSync(fullPath)) {
1111
+ deletedEntries.push(entry);
1112
+ }
1113
+ else if (!isIgnored(entry, gitRoot)) {
1114
+ trackedEntries.push(entry);
1115
+ }
1116
+ }
1117
+ if (newFiles.length === 0 && trackedEntries.length === 0 && deletedEntries.length === 0) {
1118
+ console.log(colors.green('Manifest is up to date. No new files or stale entries found.'));
1119
+ return;
1120
+ }
1121
+ let finalEntries = [...existingEntries];
1122
+ // Handle tracked entries (now tracked by git)
1123
+ if (trackedEntries.length > 0) {
1124
+ console.log(colors.bold(colors.red('\n⚠️ Warning: Files in manifest are now TRACKED by git\n')));
1125
+ console.log(colors.yellow('These files are no longer git-ignored and linking them could cause git conflicts:'));
1126
+ trackedEntries.forEach((f) => console.log(colors.yellow(` - ${f} (now tracked)`)));
1127
+ let trackedChoice = 'remove';
1128
+ if (argv.clean) {
1129
+ trackedChoice = 'remove';
1130
+ }
1131
+ else if (!argv.nonInteractive) {
1132
+ const answers = await inquirer.prompt([
1133
+ {
1134
+ type: 'list',
1135
+ name: 'trackedAction',
1136
+ message: 'How should tracked entries be handled?',
1137
+ choices: [
1138
+ {
1139
+ name: 'Remove from manifest (recommended - prevents git issues)',
1140
+ value: 'remove',
1141
+ },
1142
+ {
1143
+ name: "Comment out as # TRACKED (keep record but don't link)",
1144
+ value: 'comment',
1145
+ },
1146
+ {
1147
+ name: 'Leave unchanged (not recommended - may cause git conflicts)',
1148
+ value: 'leave',
1149
+ },
1150
+ ],
1151
+ },
1152
+ ]);
1153
+ trackedChoice = answers.trackedAction;
1154
+ }
1155
+ if (trackedChoice === 'remove') {
1156
+ finalEntries = finalEntries.filter((f) => !trackedEntries.includes(f));
1157
+ console.log(colors.red('Removed tracked entries from manifest.'));
1158
+ }
1159
+ else if (trackedChoice === 'comment') {
1160
+ finalEntries = finalEntries.map((f) => (trackedEntries.includes(f) ? `# TRACKED: ${f}` : f));
1161
+ console.log(colors.blue('Commented out tracked entries.'));
1162
+ }
1163
+ else {
1164
+ console.log(colors.yellow('Left tracked entries unchanged.'));
1165
+ }
1166
+ }
1167
+ // Handle deleted entries (no longer exist)
1168
+ if (deletedEntries.length > 0) {
1169
+ console.log(colors.bold(colors.cyan('\nℹ️ Info: Files in manifest no longer exist\n')));
1170
+ console.log(colors.dim('These files have been deleted from the filesystem:'));
1171
+ deletedEntries.forEach((f) => console.log(colors.dim(` - ${f}`)));
1172
+ let deletedChoice = 'remove';
1173
+ if (argv.clean) {
1174
+ deletedChoice = 'remove';
1175
+ }
1176
+ else if (!argv.nonInteractive) {
1177
+ const answers = await inquirer.prompt([
1178
+ {
1179
+ type: 'list',
1180
+ name: 'deletedAction',
1181
+ message: 'How should deleted entries be handled?',
1182
+ choices: [
1183
+ { name: 'Remove from manifest (clean up)', value: 'remove' },
1184
+ { name: 'Comment out as # DELETED (keep record)', value: 'comment' },
1185
+ { name: 'Leave unchanged (keep in manifest)', value: 'leave' },
1186
+ ],
1187
+ },
1188
+ ]);
1189
+ deletedChoice = answers.deletedAction;
1190
+ }
1191
+ if (deletedChoice === 'remove') {
1192
+ finalEntries = finalEntries.filter((f) => !deletedEntries.includes(f));
1193
+ console.log(colors.red('Removed deleted entries from manifest.'));
1194
+ }
1195
+ else if (deletedChoice === 'comment') {
1196
+ finalEntries = finalEntries.map((f) => (deletedEntries.includes(f) ? `# DELETED: ${f}` : f));
1197
+ console.log(colors.blue('Commented out deleted entries.'));
1198
+ }
1199
+ else {
1200
+ console.log(colors.dim('Left deleted entries unchanged.'));
1201
+ }
1202
+ }
1203
+ // Launch interactive manage with ALL ignored files and pre-populated decisions
1204
+ if (argv.nonInteractive || argv.dryRun) {
1205
+ const mode = argv.dryRun ? 'Dry run' : 'Non-interactive';
1206
+ console.log(colors.blue(`\n${mode} mode: Adding new files as commented out.`));
1207
+ finalEntries.push(...newFiles.map((f) => `# ${f}`));
1208
+ }
1209
+ else {
1210
+ console.log(colors.green('\nInteractive file management:'));
1211
+ console.log(colors.dim('Review all linkable files. Use arrow keys to navigate, letter keys for instant actions.\n'));
1212
+ // Pre-populate decisions from existing manifest
1213
+ const initialDecisions = getManifestDecisions(gitRoot, manifestFile);
1214
+ const decisions = await interactiveManage(ignoredFiles, gitRoot, initialDecisions);
1215
+ if (decisions.size === 0 && ignoredFiles.length > 0) {
1216
+ console.log(colors.yellow('Operation cancelled.'));
1217
+ return;
1218
+ }
1219
+ // Rebuild manifest from ALL decisions
1220
+ finalEntries = [];
1221
+ let addedCount = 0;
1222
+ let commentedCount = 0;
1223
+ let skippedCount = 0;
1224
+ for (const file of ignoredFiles) {
1225
+ const action = decisions.get(file);
1226
+ if (action === 'add') {
1227
+ finalEntries.push(file);
1228
+ addedCount++;
1229
+ }
1230
+ else if (action === 'comment') {
1231
+ finalEntries.push(`# ${file}`);
1232
+ commentedCount++;
1233
+ }
1234
+ else {
1235
+ // skip - don't add to manifest
1236
+ skippedCount++;
1237
+ }
1238
+ }
1239
+ console.log(colors.green(`\nSummary:`));
1240
+ console.log(colors.green(` ✓ Will Link: ${addedCount}`));
1241
+ console.log(colors.blue(` ◎ Tracked (commented): ${commentedCount}`));
1242
+ console.log(colors.yellow(` ✗ Skipped: ${skippedCount}`));
1243
+ }
1244
+ // Write manifest
1245
+ if (argv.dryRun) {
1246
+ console.log(colors.cyan('\n[DRY RUN] The following changes would be made to the manifest:'));
1247
+ if (fs.existsSync(manifestPath)) {
1248
+ console.log(colors.cyan(`- Backup existing manifest to ${manifestBackupFile}`));
1249
+ }
1250
+ console.log(colors.cyan(`- Write the following content to ${manifestFile}:`));
1251
+ console.log(colors.dim(finalEntries.join('\n') + '\n'));
1252
+ }
1253
+ else {
1254
+ if (argv.backup && fs.existsSync(manifestPath)) {
1255
+ fs.copyFileSync(manifestPath, manifestBackupFile);
1256
+ console.log(`Backed up existing manifest to ${manifestBackupFile}`);
1257
+ }
1258
+ fs.writeFileSync(manifestPath, finalEntries.join('\n') + '\n');
1259
+ console.log(colors.green(`Successfully updated ${manifestFile}.`));
1260
+ }
1261
+ }
1262
+ //# sourceMappingURL=manage-manifest.js.map