difit 2.0.2 → 2.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/dist/cli/index.js +1 -1
- package/dist/cli/index.test.js +17 -17
- package/dist/cli/utils.js +40 -2
- package/dist/cli/utils.test.js +33 -0
- package/dist/server/git-diff.js +0 -6
- package/dist/server/git-diff.test.js +26 -1
- package/dist/server/server.js +1 -1
- package/dist/server/server.test.js +3 -3
- package/dist/tui/App.js +4 -4
- package/dist/tui/components/DiffViewer.d.ts +1 -1
- package/dist/tui/components/DiffViewer.js +1 -1
- package/dist/tui/components/FileList.d.ts +1 -1
- package/dist/tui/components/FileList.js +1 -1
- package/dist/tui/components/SideBySideDiffViewer.d.ts +1 -1
- package/dist/tui/components/SideBySideDiffViewer.js +42 -39
- package/dist/tui/components/StatusBar.js +1 -1
- package/dist/tui/utils/parseDiff.d.ts +1 -1
- package/dist/utils/gitUtils.d.ts +4 -0
- package/dist/utils/gitUtils.js +29 -0
- package/dist/utils/gitUtils.test.d.ts +1 -0
- package/dist/utils/gitUtils.test.js +63 -0
- package/package.json +3 -1
package/dist/cli/index.js
CHANGED
|
@@ -16,7 +16,7 @@ program
|
|
|
16
16
|
.argument('[commit-ish]', 'Git commit, tag, branch, HEAD~n reference, or "working"/"staged"/"."', 'HEAD')
|
|
17
17
|
.argument('[compare-with]', 'Optional: Compare with this commit/branch (shows diff between commit-ish and compare-with)')
|
|
18
18
|
.option('--port <port>', 'preferred port (auto-assigned if occupied)', parseInt)
|
|
19
|
-
.option('--host <host>', 'host address to bind', '
|
|
19
|
+
.option('--host <host>', 'host address to bind', '')
|
|
20
20
|
.option('--no-open', 'do not automatically open browser')
|
|
21
21
|
.option('--mode <mode>', 'diff mode (side-by-side or inline)', 'side-by-side')
|
|
22
22
|
.option('--tui', 'use terminal UI instead of web interface')
|
package/dist/cli/index.test.js
CHANGED
|
@@ -107,7 +107,7 @@ describe('CLI index.ts', () => {
|
|
|
107
107
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
108
108
|
.argument('[compare-with]', 'compare-with')
|
|
109
109
|
.option('--port <port>', 'port', parseInt)
|
|
110
|
-
.option('--host <host>', 'host', '
|
|
110
|
+
.option('--host <host>', 'host', '')
|
|
111
111
|
.option('--no-open', 'no-open')
|
|
112
112
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
113
113
|
.option('--tui', 'tui')
|
|
@@ -149,7 +149,7 @@ describe('CLI index.ts', () => {
|
|
|
149
149
|
targetCommitish: expectedTarget,
|
|
150
150
|
baseCommitish: expectedBase,
|
|
151
151
|
preferredPort: undefined,
|
|
152
|
-
host: '
|
|
152
|
+
host: '',
|
|
153
153
|
openBrowser: true,
|
|
154
154
|
mode: 'side-by-side',
|
|
155
155
|
});
|
|
@@ -184,7 +184,7 @@ describe('CLI index.ts', () => {
|
|
|
184
184
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
185
185
|
.argument('[compare-with]', 'compare-with')
|
|
186
186
|
.option('--port <port>', 'port', parseInt)
|
|
187
|
-
.option('--host <host>', 'host', '
|
|
187
|
+
.option('--host <host>', 'host', '')
|
|
188
188
|
.option('--no-open', 'no-open')
|
|
189
189
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
190
190
|
.option('--tui', 'tui')
|
|
@@ -206,7 +206,7 @@ describe('CLI index.ts', () => {
|
|
|
206
206
|
targetCommitish: 'HEAD',
|
|
207
207
|
baseCommitish: 'HEAD^',
|
|
208
208
|
preferredPort: expectedOptions.port,
|
|
209
|
-
host: expectedOptions.host || '
|
|
209
|
+
host: expectedOptions.host || '',
|
|
210
210
|
openBrowser: expectedOptions.open !== false,
|
|
211
211
|
mode: expectedOptions.mode || 'side-by-side',
|
|
212
212
|
};
|
|
@@ -224,7 +224,7 @@ describe('CLI index.ts', () => {
|
|
|
224
224
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
225
225
|
.argument('[compare-with]', 'compare-with')
|
|
226
226
|
.option('--port <port>', 'port', parseInt)
|
|
227
|
-
.option('--host <host>', 'host', '
|
|
227
|
+
.option('--host <host>', 'host', '')
|
|
228
228
|
.option('--no-open', 'no-open')
|
|
229
229
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
230
230
|
.option('--tui', 'tui')
|
|
@@ -255,7 +255,7 @@ describe('CLI index.ts', () => {
|
|
|
255
255
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
256
256
|
.argument('[compare-with]', 'compare-with')
|
|
257
257
|
.option('--port <port>', 'port', parseInt)
|
|
258
|
-
.option('--host <host>', 'host', '
|
|
258
|
+
.option('--host <host>', 'host', '')
|
|
259
259
|
.option('--no-open', 'no-open')
|
|
260
260
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
261
261
|
.option('--tui', 'tui')
|
|
@@ -291,7 +291,7 @@ describe('CLI index.ts', () => {
|
|
|
291
291
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
292
292
|
.argument('[compare-with]', 'compare-with')
|
|
293
293
|
.option('--port <port>', 'port', parseInt)
|
|
294
|
-
.option('--host <host>', 'host', '
|
|
294
|
+
.option('--host <host>', 'host', '')
|
|
295
295
|
.option('--no-open', 'no-open')
|
|
296
296
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
297
297
|
.option('--tui', 'tui')
|
|
@@ -326,7 +326,7 @@ describe('CLI index.ts', () => {
|
|
|
326
326
|
targetCommitish: 'abc123',
|
|
327
327
|
baseCommitish: 'def456',
|
|
328
328
|
preferredPort: undefined,
|
|
329
|
-
host: '
|
|
329
|
+
host: '',
|
|
330
330
|
openBrowser: true,
|
|
331
331
|
mode: 'side-by-side',
|
|
332
332
|
});
|
|
@@ -337,7 +337,7 @@ describe('CLI index.ts', () => {
|
|
|
337
337
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
338
338
|
.argument('[compare-with]', 'compare-with')
|
|
339
339
|
.option('--port <port>', 'port', parseInt)
|
|
340
|
-
.option('--host <host>', 'host', '
|
|
340
|
+
.option('--host <host>', 'host', '')
|
|
341
341
|
.option('--no-open', 'no-open')
|
|
342
342
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
343
343
|
.option('--tui', 'tui')
|
|
@@ -370,7 +370,7 @@ describe('CLI index.ts', () => {
|
|
|
370
370
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
371
371
|
.argument('[compare-with]', 'compare-with')
|
|
372
372
|
.option('--port <port>', 'port', parseInt)
|
|
373
|
-
.option('--host <host>', 'host', '
|
|
373
|
+
.option('--host <host>', 'host', '')
|
|
374
374
|
.option('--no-open', 'no-open')
|
|
375
375
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
376
376
|
.option('--tui', 'tui')
|
|
@@ -413,7 +413,7 @@ describe('CLI index.ts', () => {
|
|
|
413
413
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
414
414
|
.argument('[compare-with]', 'compare-with')
|
|
415
415
|
.option('--port <port>', 'port', parseInt)
|
|
416
|
-
.option('--host <host>', 'host', '
|
|
416
|
+
.option('--host <host>', 'host', '')
|
|
417
417
|
.option('--no-open', 'no-open')
|
|
418
418
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
419
419
|
.option('--tui', 'tui')
|
|
@@ -447,7 +447,7 @@ describe('CLI index.ts', () => {
|
|
|
447
447
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
448
448
|
.argument('[compare-with]', 'compare-with')
|
|
449
449
|
.option('--port <port>', 'port', parseInt)
|
|
450
|
-
.option('--host <host>', 'host', '
|
|
450
|
+
.option('--host <host>', 'host', '')
|
|
451
451
|
.option('--no-open', 'no-open')
|
|
452
452
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
453
453
|
.option('--tui', 'tui')
|
|
@@ -474,7 +474,7 @@ describe('CLI index.ts', () => {
|
|
|
474
474
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
475
475
|
.argument('[compare-with]', 'compare-with')
|
|
476
476
|
.option('--port <port>', 'port', parseInt)
|
|
477
|
-
.option('--host <host>', 'host', '
|
|
477
|
+
.option('--host <host>', 'host', '')
|
|
478
478
|
.option('--no-open', 'no-open')
|
|
479
479
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
480
480
|
.option('--tui', 'tui')
|
|
@@ -528,7 +528,7 @@ describe('CLI index.ts', () => {
|
|
|
528
528
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
529
529
|
.argument('[compare-with]', 'compare-with')
|
|
530
530
|
.option('--port <port>', 'port', parseInt)
|
|
531
|
-
.option('--host <host>', 'host', '
|
|
531
|
+
.option('--host <host>', 'host', '')
|
|
532
532
|
.option('--no-open', 'no-open')
|
|
533
533
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
534
534
|
.option('--tui', 'tui')
|
|
@@ -566,7 +566,7 @@ describe('CLI index.ts', () => {
|
|
|
566
566
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
567
567
|
.argument('[compare-with]', 'compare-with')
|
|
568
568
|
.option('--port <port>', 'port', parseInt)
|
|
569
|
-
.option('--host <host>', 'host', '
|
|
569
|
+
.option('--host <host>', 'host', '')
|
|
570
570
|
.option('--no-open', 'no-open')
|
|
571
571
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
572
572
|
.option('--tui', 'tui')
|
|
@@ -604,7 +604,7 @@ describe('CLI index.ts', () => {
|
|
|
604
604
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
605
605
|
.argument('[compare-with]', 'compare-with')
|
|
606
606
|
.option('--port <port>', 'port', parseInt)
|
|
607
|
-
.option('--host <host>', 'host', '
|
|
607
|
+
.option('--host <host>', 'host', '')
|
|
608
608
|
.option('--no-open', 'no-open')
|
|
609
609
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
610
610
|
.option('--tui', 'tui')
|
|
@@ -653,7 +653,7 @@ describe('CLI index.ts', () => {
|
|
|
653
653
|
.argument('[commit-ish]', 'commit-ish', 'HEAD')
|
|
654
654
|
.argument('[compare-with]', 'compare-with')
|
|
655
655
|
.option('--port <port>', 'port', parseInt)
|
|
656
|
-
.option('--host <host>', 'host', '
|
|
656
|
+
.option('--host <host>', 'host', '')
|
|
657
657
|
.option('--no-open', 'no-open')
|
|
658
658
|
.option('--mode <mode>', 'mode', 'side-by-side')
|
|
659
659
|
.option('--tui', 'tui')
|
package/dist/cli/utils.js
CHANGED
|
@@ -21,9 +21,47 @@ export function validateCommitish(commitish) {
|
|
|
21
21
|
/^[a-f0-9]{4,40}\^+$/i, // SHA hashes with ^ suffix (parent references)
|
|
22
22
|
/^[a-f0-9]{4,40}~\d+$/i, // SHA hashes with ~N suffix (ancestor references)
|
|
23
23
|
/^HEAD(~\d+|\^\d*)*$/, // HEAD, HEAD~1, HEAD^, HEAD^2, etc.
|
|
24
|
-
/^[a-zA-Z][a-zA-Z0-9_\-/.@]*$/, // branch names, tags (must start with letter, no ^ or ~ in middle)
|
|
25
24
|
];
|
|
26
|
-
|
|
25
|
+
// Check if it matches any specific patterns first
|
|
26
|
+
if (validPatterns.some((pattern) => pattern.test(trimmed))) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
// For branch names, use git's rules
|
|
30
|
+
return isValidBranchName(trimmed);
|
|
31
|
+
}
|
|
32
|
+
function isValidBranchName(name) {
|
|
33
|
+
// Git branch name rules
|
|
34
|
+
if (name.startsWith('-'))
|
|
35
|
+
return false; // Cannot start with dash
|
|
36
|
+
if (name.endsWith('.'))
|
|
37
|
+
return false; // Cannot end with dot
|
|
38
|
+
if (name === '@')
|
|
39
|
+
return false; // Cannot be just @
|
|
40
|
+
if (name.includes('..'))
|
|
41
|
+
return false; // No consecutive dots
|
|
42
|
+
if (name.includes('@{'))
|
|
43
|
+
return false; // No @{ sequence
|
|
44
|
+
if (name.includes('//'))
|
|
45
|
+
return false; // No consecutive slashes
|
|
46
|
+
if (name.startsWith('/') || name.endsWith('/'))
|
|
47
|
+
return false; // No leading/trailing slashes
|
|
48
|
+
if (name.endsWith('.lock'))
|
|
49
|
+
return false; // Cannot end with .lock
|
|
50
|
+
// Check for forbidden characters
|
|
51
|
+
const forbiddenChars = /[~^:?*[\\\x00-\x20\x7F]/;
|
|
52
|
+
if (forbiddenChars.test(name))
|
|
53
|
+
return false;
|
|
54
|
+
// Check path components
|
|
55
|
+
const components = name.split('/');
|
|
56
|
+
for (const component of components) {
|
|
57
|
+
if (component === '')
|
|
58
|
+
return false; // Empty component
|
|
59
|
+
if (component.startsWith('.'))
|
|
60
|
+
return false; // Component cannot start with dot
|
|
61
|
+
if (component.endsWith('.lock'))
|
|
62
|
+
return false; // Component cannot end with .lock
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
27
65
|
}
|
|
28
66
|
export function shortHash(hash) {
|
|
29
67
|
return hash.substring(0, 7);
|
package/dist/cli/utils.test.js
CHANGED
|
@@ -28,9 +28,22 @@ describe('CLI Utils', () => {
|
|
|
28
28
|
expect(validateCommitish('HEAD~2^1')).toBe(true);
|
|
29
29
|
});
|
|
30
30
|
it('should validate branch names', () => {
|
|
31
|
+
// Valid branch names according to git rules
|
|
31
32
|
expect(validateCommitish('main')).toBe(true);
|
|
32
33
|
expect(validateCommitish('feature/new-feature')).toBe(true);
|
|
33
34
|
expect(validateCommitish('develop')).toBe(true);
|
|
35
|
+
expect(validateCommitish('feature-123')).toBe(true); // dash and numbers (not at start)
|
|
36
|
+
expect(validateCommitish('feature_branch')).toBe(true); // underscore
|
|
37
|
+
expect(validateCommitish('hotfix@bug')).toBe(true); // @ character (not followed by {)
|
|
38
|
+
expect(validateCommitish('feature+new')).toBe(true); // plus character
|
|
39
|
+
expect(validateCommitish('feature=test')).toBe(true); // equals character
|
|
40
|
+
expect(validateCommitish('feature!important')).toBe(true); // exclamation
|
|
41
|
+
expect(validateCommitish('feature,list')).toBe(true); // comma
|
|
42
|
+
expect(validateCommitish('feature;test')).toBe(true); // semicolon
|
|
43
|
+
expect(validateCommitish('feature"quoted"')).toBe(true); // quotes
|
|
44
|
+
expect(validateCommitish("feature'quoted'")).toBe(true); // single quotes
|
|
45
|
+
expect(validateCommitish('release/v2.3.1')).toBe(true); // version numbers
|
|
46
|
+
expect(validateCommitish('bugfix/login-timeout')).toBe(true); // path with dash
|
|
34
47
|
});
|
|
35
48
|
it('should validate special cases', () => {
|
|
36
49
|
expect(validateCommitish('.')).toBe(true); // working directory diff
|
|
@@ -40,6 +53,26 @@ describe('CLI Utils', () => {
|
|
|
40
53
|
expect(validateCommitish(' ')).toBe(false);
|
|
41
54
|
expect(validateCommitish('HEAD~')).toBe(false);
|
|
42
55
|
expect(validateCommitish('abc')).toBe(true); // short hashes are valid
|
|
56
|
+
// Invalid branch names according to git rules
|
|
57
|
+
expect(validateCommitish('-feature')).toBe(false); // cannot start with dash
|
|
58
|
+
expect(validateCommitish('feature.')).toBe(false); // cannot end with dot
|
|
59
|
+
expect(validateCommitish('@')).toBe(false); // cannot be just @
|
|
60
|
+
expect(validateCommitish('feature..test')).toBe(false); // no consecutive dots
|
|
61
|
+
expect(validateCommitish('feature@{upstream}')).toBe(false); // no @{ sequence
|
|
62
|
+
expect(validateCommitish('feature//test')).toBe(false); // no consecutive slashes
|
|
63
|
+
expect(validateCommitish('/feature')).toBe(false); // cannot start with slash
|
|
64
|
+
expect(validateCommitish('feature/')).toBe(false); // cannot end with slash
|
|
65
|
+
expect(validateCommitish('feature.lock')).toBe(false); // cannot end with .lock
|
|
66
|
+
expect(validateCommitish('feature^invalid')).toBe(false); // ^ not allowed
|
|
67
|
+
expect(validateCommitish('feature~invalid')).toBe(false); // ~ not allowed
|
|
68
|
+
expect(validateCommitish('feature:invalid')).toBe(false); // : not allowed
|
|
69
|
+
expect(validateCommitish('feature?invalid')).toBe(false); // ? not allowed
|
|
70
|
+
expect(validateCommitish('feature*invalid')).toBe(false); // * not allowed
|
|
71
|
+
expect(validateCommitish('feature[invalid')).toBe(false); // [ not allowed
|
|
72
|
+
expect(validateCommitish('feature\\invalid')).toBe(false); // \ not allowed
|
|
73
|
+
expect(validateCommitish('feature invalid')).toBe(false); // space not allowed
|
|
74
|
+
expect(validateCommitish('feature/.hidden')).toBe(false); // component cannot start with dot
|
|
75
|
+
expect(validateCommitish('feature/test.lock')).toBe(false); // component cannot end with .lock
|
|
43
76
|
});
|
|
44
77
|
it('should reject non-string input', () => {
|
|
45
78
|
expect(validateCommitish(null)).toBe(false);
|
package/dist/server/git-diff.js
CHANGED
|
@@ -99,12 +99,6 @@ export class GitDiffParser {
|
|
|
99
99
|
else if (oldPath !== newPath) {
|
|
100
100
|
status = 'renamed';
|
|
101
101
|
}
|
|
102
|
-
else if (summary.insertions && !summary.deletions) {
|
|
103
|
-
status = 'added';
|
|
104
|
-
}
|
|
105
|
-
else if (summary.deletions && !summary.insertions) {
|
|
106
|
-
status = 'deleted';
|
|
107
|
-
}
|
|
108
102
|
// For binary files, don't try to parse chunks
|
|
109
103
|
const chunks = summary.binary ? [] : this.parseChunks(lines);
|
|
110
104
|
return {
|
|
@@ -213,7 +213,7 @@ describe('GitDiffParser', () => {
|
|
|
213
213
|
expect(result).toEqual({
|
|
214
214
|
path: 'script.js',
|
|
215
215
|
oldPath: undefined,
|
|
216
|
-
status: '
|
|
216
|
+
status: 'modified',
|
|
217
217
|
additions: 1,
|
|
218
218
|
deletions: 0,
|
|
219
219
|
chunks: expect.any(Array), // Should have parsed chunks
|
|
@@ -222,6 +222,31 @@ describe('GitDiffParser', () => {
|
|
|
222
222
|
expect(result.chunks).toHaveLength(1);
|
|
223
223
|
expect(result.chunks[0].header).toBe('@@ -1,3 +1,4 @@');
|
|
224
224
|
});
|
|
225
|
+
it('treats files with only deletions as modified', () => {
|
|
226
|
+
const diffLines = [
|
|
227
|
+
'diff --git a/script.js b/script.js',
|
|
228
|
+
'index abc123..def456 100644',
|
|
229
|
+
'--- a/script.js',
|
|
230
|
+
'+++ b/script.js',
|
|
231
|
+
'@@ -1,4 +1,3 @@',
|
|
232
|
+
' console.log("hello");',
|
|
233
|
+
'-console.log("world");',
|
|
234
|
+
' // end',
|
|
235
|
+
];
|
|
236
|
+
const summary = {
|
|
237
|
+
insertions: 0,
|
|
238
|
+
deletions: 1,
|
|
239
|
+
};
|
|
240
|
+
const result = parser.parseFileBlock(diffLines.join('\n'), summary);
|
|
241
|
+
expect(result).toEqual({
|
|
242
|
+
path: 'script.js',
|
|
243
|
+
oldPath: undefined,
|
|
244
|
+
status: 'modified',
|
|
245
|
+
additions: 0,
|
|
246
|
+
deletions: 1,
|
|
247
|
+
chunks: expect.any(Array),
|
|
248
|
+
});
|
|
249
|
+
});
|
|
225
250
|
it('detects added files using /dev/null indicator', () => {
|
|
226
251
|
const diffLines = [
|
|
227
252
|
'diff --git a/new-file.txt b/new-file.txt',
|
package/dist/server/server.js
CHANGED
|
@@ -176,7 +176,7 @@ export async function startServer(options) {
|
|
|
176
176
|
`);
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
-
const { port, url, server } = await startServerWithFallback(app, options.preferredPort || 3000, options.host || '
|
|
179
|
+
const { port, url, server } = await startServerWithFallback(app, options.preferredPort || 3000, options.host || 'localhost');
|
|
180
180
|
// Security warning for non-localhost binding
|
|
181
181
|
if (options.host && options.host !== '127.0.0.1' && options.host !== 'localhost') {
|
|
182
182
|
console.warn('\n⚠️ WARNING: Server is accessible from external network!');
|
|
@@ -60,7 +60,7 @@ describe('Server Integration Tests', () => {
|
|
|
60
60
|
});
|
|
61
61
|
servers.push(result.server); // Track for cleanup
|
|
62
62
|
expect(result.port).toBeGreaterThanOrEqual(preferredPort);
|
|
63
|
-
expect(result.url).toContain('http://
|
|
63
|
+
expect(result.url).toContain('http://localhost:');
|
|
64
64
|
expect(result.isEmpty).toBe(false);
|
|
65
65
|
});
|
|
66
66
|
it('falls back to next port when preferred is occupied', async () => {
|
|
@@ -82,7 +82,7 @@ describe('Server Integration Tests', () => {
|
|
|
82
82
|
servers.push(secondServer.server);
|
|
83
83
|
expect(firstServer.port).toBeGreaterThanOrEqual(preferredPort);
|
|
84
84
|
expect(secondServer.port).toBe(firstServer.port + 1);
|
|
85
|
-
expect(secondServer.url).toBe(`http://
|
|
85
|
+
expect(secondServer.url).toBe(`http://localhost:${secondServer.port}`);
|
|
86
86
|
});
|
|
87
87
|
it('binds to specified host', async () => {
|
|
88
88
|
const result = await startServer({
|
|
@@ -261,7 +261,7 @@ describe('Server Integration Tests', () => {
|
|
|
261
261
|
});
|
|
262
262
|
servers.push(result.server);
|
|
263
263
|
expect(result.port).toBeGreaterThanOrEqual(3000);
|
|
264
|
-
expect(result.url).toContain('http://
|
|
264
|
+
expect(result.url).toContain('http://localhost:');
|
|
265
265
|
});
|
|
266
266
|
it('accepts different mode values', async () => {
|
|
267
267
|
const inlineResult = await startServer({
|
package/dist/tui/App.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
1
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
3
|
import { loadGitDiff } from '../server/git-diff-tui.js';
|
|
4
|
-
import FileList from './components/FileList.js';
|
|
5
4
|
import DiffViewer from './components/DiffViewer.js';
|
|
5
|
+
import FileList from './components/FileList.js';
|
|
6
6
|
import SideBySideDiffViewer from './components/SideBySideDiffViewer.js';
|
|
7
7
|
import StatusBar from './components/StatusBar.js';
|
|
8
8
|
const App = ({ targetCommitish, baseCommitish, mode }) => {
|
|
@@ -26,7 +26,7 @@ const App = ({ targetCommitish, baseCommitish, mode }) => {
|
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
useEffect(() => {
|
|
29
|
-
loadDiff();
|
|
29
|
+
void loadDiff();
|
|
30
30
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
31
|
}, [targetCommitish, baseCommitish]);
|
|
32
32
|
useInput((input, key) => {
|
|
@@ -35,7 +35,7 @@ const App = ({ targetCommitish, baseCommitish, mode }) => {
|
|
|
35
35
|
}
|
|
36
36
|
// Reload on 'r' key
|
|
37
37
|
if (input === 'r') {
|
|
38
|
-
loadDiff();
|
|
38
|
+
void loadDiff();
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
if (viewMode === 'list') {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
1
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
3
|
const DiffViewer = ({ files, initialFileIndex }) => {
|
|
4
4
|
const [currentFileIndex, setCurrentFileIndex] = useState(initialFileIndex);
|
|
5
5
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -1,10 +1,48 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
1
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
3
|
import { parseDiff } from '../utils/parseDiff.js';
|
|
4
4
|
const SideBySideDiffViewer = ({ files, initialFileIndex, onBack, }) => {
|
|
5
5
|
const [currentFileIndex, setCurrentFileIndex] = useState(initialFileIndex);
|
|
6
6
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const viewportHeight = Math.max(10, (process.stdout.rows || 24) - 10);
|
|
7
9
|
const currentFile = files[currentFileIndex];
|
|
10
|
+
useInput((input, key) => {
|
|
11
|
+
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
12
|
+
exit();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (key.escape || input === 'b') {
|
|
16
|
+
onBack();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!currentFile)
|
|
20
|
+
return;
|
|
21
|
+
// Scroll within file
|
|
22
|
+
if (key.upArrow || input === 'k') {
|
|
23
|
+
setScrollOffset((prev) => Math.max(0, prev - 1));
|
|
24
|
+
}
|
|
25
|
+
if (key.downArrow || input === 'j') {
|
|
26
|
+
setScrollOffset((prev) => prev + 1);
|
|
27
|
+
}
|
|
28
|
+
if (key.pageUp) {
|
|
29
|
+
setScrollOffset((prev) => Math.max(0, prev - viewportHeight));
|
|
30
|
+
}
|
|
31
|
+
if (key.pageDown) {
|
|
32
|
+
setScrollOffset((prev) => prev + viewportHeight);
|
|
33
|
+
}
|
|
34
|
+
// Navigate between files
|
|
35
|
+
if (key.tab && !key.shift) {
|
|
36
|
+
// Next file (loop to first when at end)
|
|
37
|
+
setCurrentFileIndex((currentFileIndex + 1) % files.length);
|
|
38
|
+
setScrollOffset(0);
|
|
39
|
+
}
|
|
40
|
+
if (key.tab && key.shift) {
|
|
41
|
+
// Previous file (loop to last when at start)
|
|
42
|
+
setCurrentFileIndex((currentFileIndex - 1 + files.length) % files.length);
|
|
43
|
+
setScrollOffset(0);
|
|
44
|
+
}
|
|
45
|
+
}, { isActive: true });
|
|
8
46
|
if (!currentFile || files.length === 0) {
|
|
9
47
|
return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
10
48
|
React.createElement(Text, { color: "yellow" }, "No files to display"),
|
|
@@ -74,44 +112,9 @@ const SideBySideDiffViewer = ({ files, initialFileIndex, onBack, }) => {
|
|
|
74
112
|
}
|
|
75
113
|
}
|
|
76
114
|
});
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
useInput((input, key) => {
|
|
81
|
-
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
82
|
-
exit();
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (key.escape || input === 'b') {
|
|
86
|
-
onBack();
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
// Scroll within file
|
|
90
|
-
if (key.upArrow || input === 'k') {
|
|
91
|
-
setScrollOffset((prev) => Math.max(0, prev - 1));
|
|
92
|
-
}
|
|
93
|
-
if (key.downArrow || input === 'j') {
|
|
94
|
-
setScrollOffset((prev) => Math.min(maxScroll, prev + 1));
|
|
95
|
-
}
|
|
96
|
-
if (key.pageUp) {
|
|
97
|
-
setScrollOffset((prev) => Math.max(0, prev - viewportHeight));
|
|
98
|
-
}
|
|
99
|
-
if (key.pageDown) {
|
|
100
|
-
setScrollOffset((prev) => Math.min(maxScroll, prev + viewportHeight));
|
|
101
|
-
}
|
|
102
|
-
// Navigate between files
|
|
103
|
-
if (key.tab && !key.shift) {
|
|
104
|
-
// Next file (loop to first when at end)
|
|
105
|
-
setCurrentFileIndex((currentFileIndex + 1) % files.length);
|
|
106
|
-
setScrollOffset(0);
|
|
107
|
-
}
|
|
108
|
-
if (key.tab && key.shift) {
|
|
109
|
-
// Previous file (loop to last when at start)
|
|
110
|
-
setCurrentFileIndex((currentFileIndex - 1 + files.length) % files.length);
|
|
111
|
-
setScrollOffset(0);
|
|
112
|
-
}
|
|
113
|
-
}, { isActive: true });
|
|
114
|
-
const visibleLines = allLines.slice(scrollOffset, scrollOffset + viewportHeight);
|
|
115
|
+
const actualMaxScroll = Math.max(0, allLines.length - viewportHeight);
|
|
116
|
+
const clampedScrollOffset = Math.max(0, Math.min(actualMaxScroll, scrollOffset));
|
|
117
|
+
const visibleLines = allLines.slice(clampedScrollOffset, clampedScrollOffset + viewportHeight);
|
|
115
118
|
const terminalWidth = process.stdout.columns || 80;
|
|
116
119
|
const columnWidth = Math.floor((terminalWidth - 6) / 2); // 6 for borders and separators
|
|
117
120
|
const getLineColor = (type) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
3
|
const StatusBar = ({ commitish, totalFiles, currentMode }) => {
|
|
4
4
|
return (React.createElement(Box, { borderStyle: "round", paddingX: 1, marginBottom: 1 },
|
|
5
5
|
React.createElement(Box, { flexGrow: 1 },
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { ParsedDiff } from '../../types/diff.js';
|
|
1
|
+
import { type ParsedDiff } from '../../types/diff.js';
|
|
2
2
|
export declare function parseDiff(diffText: string): ParsedDiff;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SimpleGit } from 'simple-git';
|
|
2
|
+
export declare function shortHash(hash: string): string;
|
|
3
|
+
export declare function createCommitRangeString(baseHash: string, targetHash: string): string;
|
|
4
|
+
export declare function resolveCommitDisplayString(baseCommitish: string, targetCommitish: string, git: SimpleGit): Promise<string>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function shortHash(hash) {
|
|
2
|
+
return hash.substring(0, 7);
|
|
3
|
+
}
|
|
4
|
+
export function createCommitRangeString(baseHash, targetHash) {
|
|
5
|
+
return `${baseHash}...${targetHash}`;
|
|
6
|
+
}
|
|
7
|
+
export async function resolveCommitDisplayString(baseCommitish, targetCommitish, git) {
|
|
8
|
+
// Handle target special chars (base is always a regular commit)
|
|
9
|
+
if (targetCommitish === 'working') {
|
|
10
|
+
// Show unstaged changes (working vs staged)
|
|
11
|
+
return 'Working Directory (unstaged changes)';
|
|
12
|
+
}
|
|
13
|
+
else if (targetCommitish === 'staged') {
|
|
14
|
+
// Show staged changes against base commit
|
|
15
|
+
const baseHash = await git.revparse([baseCommitish]);
|
|
16
|
+
return `${shortHash(baseHash)} vs Staging Area (staged changes)`;
|
|
17
|
+
}
|
|
18
|
+
else if (targetCommitish === '.') {
|
|
19
|
+
// Show all uncommitted changes against base commit
|
|
20
|
+
const baseHash = await git.revparse([baseCommitish]);
|
|
21
|
+
return `${shortHash(baseHash)} vs Working Directory (all uncommitted changes)`;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
// Both are regular commits: standard commit-to-commit comparison
|
|
25
|
+
const targetHash = await git.revparse([targetCommitish]);
|
|
26
|
+
const baseHash = await git.revparse([baseCommitish]);
|
|
27
|
+
return createCommitRangeString(shortHash(baseHash), shortHash(targetHash));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { shortHash, createCommitRangeString, resolveCommitDisplayString } from './gitUtils';
|
|
3
|
+
describe('Git Utils', () => {
|
|
4
|
+
describe('shortHash', () => {
|
|
5
|
+
it('should return first 7 characters of hash', () => {
|
|
6
|
+
expect(shortHash('a1b2c3d4e5f6789012345678901234567890abcd')).toBe('a1b2c3d');
|
|
7
|
+
expect(shortHash('1234567890abcdef')).toBe('1234567');
|
|
8
|
+
expect(shortHash('abc123')).toBe('abc123');
|
|
9
|
+
});
|
|
10
|
+
it('should handle short hashes', () => {
|
|
11
|
+
expect(shortHash('abc')).toBe('abc');
|
|
12
|
+
expect(shortHash('')).toBe('');
|
|
13
|
+
expect(shortHash('a')).toBe('a');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('createCommitRangeString', () => {
|
|
17
|
+
it('should create commit range string with triple dots', () => {
|
|
18
|
+
expect(createCommitRangeString('abc1234', 'def5678')).toBe('abc1234...def5678');
|
|
19
|
+
expect(createCommitRangeString('a1b2c3d', '4e5f6g7')).toBe('a1b2c3d...4e5f6g7');
|
|
20
|
+
});
|
|
21
|
+
it('should handle empty strings', () => {
|
|
22
|
+
expect(createCommitRangeString('', '')).toBe('...');
|
|
23
|
+
expect(createCommitRangeString('abc123', '')).toBe('abc123...');
|
|
24
|
+
expect(createCommitRangeString('', 'def456')).toBe('...def456');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('resolveCommitDisplayString', () => {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
const mockGit = {
|
|
30
|
+
revparse: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
it('should handle working directory target', async () => {
|
|
36
|
+
const result = await resolveCommitDisplayString('main', 'working', mockGit);
|
|
37
|
+
expect(result).toBe('Working Directory (unstaged changes)');
|
|
38
|
+
expect(mockGit.revparse).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
it('should handle staged target', async () => {
|
|
41
|
+
mockGit.revparse.mockResolvedValue('a1b2c3d4e5f6789012345678901234567890abcd');
|
|
42
|
+
const result = await resolveCommitDisplayString('main', 'staged', mockGit);
|
|
43
|
+
expect(result).toBe('a1b2c3d vs Staging Area (staged changes)');
|
|
44
|
+
expect(mockGit.revparse).toHaveBeenCalledWith(['main']);
|
|
45
|
+
});
|
|
46
|
+
it('should handle dot (.) target', async () => {
|
|
47
|
+
mockGit.revparse.mockResolvedValue('a1b2c3d4e5f6789012345678901234567890abcd');
|
|
48
|
+
const result = await resolveCommitDisplayString('main', '.', mockGit);
|
|
49
|
+
expect(result).toBe('a1b2c3d vs Working Directory (all uncommitted changes)');
|
|
50
|
+
expect(mockGit.revparse).toHaveBeenCalledWith(['main']);
|
|
51
|
+
});
|
|
52
|
+
it('should handle regular commit to commit comparison', async () => {
|
|
53
|
+
mockGit.revparse
|
|
54
|
+
.mockResolvedValueOnce('def4567890abcdef1234567890abcdef12345678') // target (called first)
|
|
55
|
+
.mockResolvedValueOnce('a1b2c3d4e5f6789012345678901234567890abcd'); // base (called second)
|
|
56
|
+
const result = await resolveCommitDisplayString('main', 'develop', mockGit);
|
|
57
|
+
expect(result).toBe('a1b2c3d...def4567');
|
|
58
|
+
expect(mockGit.revparse).toHaveBeenCalledWith(['develop']);
|
|
59
|
+
expect(mockGit.revparse).toHaveBeenCalledWith(['main']);
|
|
60
|
+
expect(mockGit.revparse).toHaveBeenCalledTimes(2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "difit",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/eslintrc": "^3.3.1",
|
|
54
|
+
"@eslint/js": "^9.30.1",
|
|
54
55
|
"@prettier/plugin-oxc": "^0.0.4",
|
|
55
56
|
"@tailwindcss/forms": "^0.5.10",
|
|
56
57
|
"@tailwindcss/postcss": "^4.1.11",
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"eslint-plugin-react": "^7.37.5",
|
|
75
76
|
"eslint-plugin-react-hooks": "^5.2.0",
|
|
76
77
|
"eslint-plugin-unused-imports": "^4.1.4",
|
|
78
|
+
"globals": "^16.3.0",
|
|
77
79
|
"happy-dom": "^18.0.1",
|
|
78
80
|
"lefthook": "^1.11.14",
|
|
79
81
|
"postcss": "^8.5.6",
|