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 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', '127.0.0.1')
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')
@@ -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', '127.0.0.1')
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: '127.0.0.1',
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', '127.0.0.1')
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 || '127.0.0.1',
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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: '127.0.0.1',
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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', '127.0.0.1')
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
- return validPatterns.some((pattern) => pattern.test(trimmed));
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);
@@ -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);
@@ -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: 'added',
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',
@@ -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 || '127.0.0.1');
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://127.0.0.1:');
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://127.0.0.1:${secondServer.port}`);
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://127.0.0.1:');
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
1
  import React from 'react';
2
- import { FileDiff } from '../../types/diff.js';
2
+ import { type FileDiff } from '../../types/diff.js';
3
3
  interface DiffViewerProps {
4
4
  files: FileDiff[];
5
5
  initialFileIndex: number;
@@ -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,5 +1,5 @@
1
1
  import React from 'react';
2
- import { FileDiff } from '../../types/diff.js';
2
+ import { type FileDiff } from '../../types/diff.js';
3
3
  interface FileListProps {
4
4
  files: FileDiff[];
5
5
  selectedIndex: number;
@@ -1,5 +1,5 @@
1
- import React from 'react';
2
1
  import { Box, Text } from 'ink';
2
+ import React from 'react';
3
3
  const FileList = ({ files, selectedIndex }) => {
4
4
  const getStatusColor = (status) => {
5
5
  switch (status) {
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { FileDiff } from '../../types/diff.js';
2
+ import { type FileDiff } from '../../types/diff.js';
3
3
  interface SideBySideDiffViewerProps {
4
4
  files: FileDiff[];
5
5
  initialFileIndex: number;
@@ -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 viewportHeight = Math.max(10, (process.stdout.rows || 24) - 10); // StatusBar(3) + file nav(3) + footer(3) + margin(1)
78
- const maxScroll = Math.max(0, allLines.length - viewportHeight);
79
- const { exit } = useApp();
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.2",
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",