@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.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/dist/cli/cleanpr.d.ts +13 -0
- package/dist/cli/cleanpr.d.ts.map +1 -0
- package/dist/cli/cleanpr.js +441 -0
- package/dist/cli/cleanpr.js.map +1 -0
- package/dist/cli/lswt.d.ts +11 -0
- package/dist/cli/lswt.d.ts.map +1 -0
- package/dist/cli/lswt.js +313 -0
- package/dist/cli/lswt.js.map +1 -0
- package/dist/cli/newpr.d.ts +11 -0
- package/dist/cli/newpr.d.ts.map +1 -0
- package/dist/cli/newpr.js +888 -0
- package/dist/cli/newpr.js.map +1 -0
- package/dist/cli/wtlink.d.ts +15 -0
- package/dist/cli/wtlink.d.ts.map +1 -0
- package/dist/cli/wtlink.js +124 -0
- package/dist/cli/wtlink.js.map +1 -0
- package/dist/e2e/cli.e2e.test.d.ts +2 -0
- package/dist/e2e/cli.e2e.test.d.ts.map +1 -0
- package/dist/e2e/cli.e2e.test.js +215 -0
- package/dist/e2e/cli.e2e.test.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/integration/git.integration.test.d.ts +2 -0
- package/dist/integration/git.integration.test.d.ts.map +1 -0
- package/dist/integration/git.integration.test.js +333 -0
- package/dist/integration/git.integration.test.js.map +1 -0
- package/dist/lib/colors.d.ts +59 -0
- package/dist/lib/colors.d.ts.map +1 -0
- package/dist/lib/colors.js +145 -0
- package/dist/lib/colors.js.map +1 -0
- package/dist/lib/colors.test.d.ts +2 -0
- package/dist/lib/colors.test.d.ts.map +1 -0
- package/dist/lib/colors.test.js +69 -0
- package/dist/lib/colors.test.js.map +1 -0
- package/dist/lib/config.d.ts +58 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +91 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/config.test.d.ts +2 -0
- package/dist/lib/config.test.d.ts.map +1 -0
- package/dist/lib/config.test.js +84 -0
- package/dist/lib/config.test.js.map +1 -0
- package/dist/lib/constants.d.ts +37 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +37 -0
- package/dist/lib/constants.js.map +1 -0
- package/dist/lib/errors.d.ts +88 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +112 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/errors.test.d.ts +2 -0
- package/dist/lib/errors.test.d.ts.map +1 -0
- package/dist/lib/errors.test.js +117 -0
- package/dist/lib/errors.test.js.map +1 -0
- package/dist/lib/git.d.ts +224 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +524 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/git.test.d.ts +2 -0
- package/dist/lib/git.test.d.ts.map +1 -0
- package/dist/lib/git.test.js +402 -0
- package/dist/lib/git.test.js.map +1 -0
- package/dist/lib/github.d.ts +82 -0
- package/dist/lib/github.d.ts.map +1 -0
- package/dist/lib/github.js +254 -0
- package/dist/lib/github.js.map +1 -0
- package/dist/lib/github.test.d.ts +2 -0
- package/dist/lib/github.test.d.ts.map +1 -0
- package/dist/lib/github.test.js +258 -0
- package/dist/lib/github.test.js.map +1 -0
- package/dist/lib/prompts.d.ts +39 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +213 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/lib/prompts.test.d.ts +2 -0
- package/dist/lib/prompts.test.d.ts.map +1 -0
- package/dist/lib/prompts.test.js +250 -0
- package/dist/lib/prompts.test.js.map +1 -0
- package/dist/lib/state-detection.d.ts +65 -0
- package/dist/lib/state-detection.d.ts.map +1 -0
- package/dist/lib/state-detection.js +186 -0
- package/dist/lib/state-detection.js.map +1 -0
- package/dist/lib/state-detection.test.d.ts +2 -0
- package/dist/lib/state-detection.test.d.ts.map +1 -0
- package/dist/lib/state-detection.test.js +164 -0
- package/dist/lib/state-detection.test.js.map +1 -0
- package/dist/lib/wtlink/index.d.ts +5 -0
- package/dist/lib/wtlink/index.d.ts.map +1 -0
- package/dist/lib/wtlink/index.js +7 -0
- package/dist/lib/wtlink/index.js.map +1 -0
- package/dist/lib/wtlink/link-configs.d.ts +10 -0
- package/dist/lib/wtlink/link-configs.d.ts.map +1 -0
- package/dist/lib/wtlink/link-configs.js +411 -0
- package/dist/lib/wtlink/link-configs.js.map +1 -0
- package/dist/lib/wtlink/link-configs.test.d.ts +2 -0
- package/dist/lib/wtlink/link-configs.test.d.ts.map +1 -0
- package/dist/lib/wtlink/link-configs.test.js +179 -0
- package/dist/lib/wtlink/link-configs.test.js.map +1 -0
- package/dist/lib/wtlink/main-menu.d.ts +2 -0
- package/dist/lib/wtlink/main-menu.d.ts.map +1 -0
- package/dist/lib/wtlink/main-menu.js +149 -0
- package/dist/lib/wtlink/main-menu.js.map +1 -0
- package/dist/lib/wtlink/manage-manifest.d.ts +9 -0
- package/dist/lib/wtlink/manage-manifest.d.ts.map +1 -0
- package/dist/lib/wtlink/manage-manifest.js +1262 -0
- package/dist/lib/wtlink/manage-manifest.js.map +1 -0
- package/dist/lib/wtlink/validate-manifest.d.ts +6 -0
- package/dist/lib/wtlink/validate-manifest.d.ts.map +1 -0
- package/dist/lib/wtlink/validate-manifest.js +51 -0
- package/dist/lib/wtlink/validate-manifest.js.map +1 -0
- package/dist/lib/wtlink/validate-manifest.test.d.ts +2 -0
- package/dist/lib/wtlink/validate-manifest.test.d.ts.map +1 -0
- package/dist/lib/wtlink/validate-manifest.test.js +115 -0
- package/dist/lib/wtlink/validate-manifest.test.js.map +1 -0
- 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
|