diffity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -0
  4. package/development.md +156 -0
  5. package/package.json +32 -0
  6. package/packages/cli/build.js +38 -0
  7. package/packages/cli/package.json +51 -0
  8. package/packages/cli/src/agent.ts +187 -0
  9. package/packages/cli/src/db.ts +58 -0
  10. package/packages/cli/src/index.ts +196 -0
  11. package/packages/cli/src/review-routes.ts +150 -0
  12. package/packages/cli/src/server.ts +370 -0
  13. package/packages/cli/src/session.ts +48 -0
  14. package/packages/cli/src/threads.ts +238 -0
  15. package/packages/cli/tsconfig.json +13 -0
  16. package/packages/git/package.json +24 -0
  17. package/packages/git/src/commits.ts +28 -0
  18. package/packages/git/src/diff.ts +97 -0
  19. package/packages/git/src/exec.ts +35 -0
  20. package/packages/git/src/index.ts +5 -0
  21. package/packages/git/src/repo.ts +63 -0
  22. package/packages/git/src/status.ts +9 -0
  23. package/packages/git/src/types.ts +12 -0
  24. package/packages/git/tsconfig.json +9 -0
  25. package/packages/parser/package.json +26 -0
  26. package/packages/parser/src/index.ts +12 -0
  27. package/packages/parser/src/parse.ts +299 -0
  28. package/packages/parser/src/types.ts +52 -0
  29. package/packages/parser/src/word-diff.ts +155 -0
  30. package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
  31. package/packages/parser/tests/fixtures/binary-file.diff +4 -0
  32. package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
  33. package/packages/parser/tests/fixtures/copied-file.diff +12 -0
  34. package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
  35. package/packages/parser/tests/fixtures/empty.diff +0 -0
  36. package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
  37. package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
  38. package/packages/parser/tests/fixtures/mode-change.diff +3 -0
  39. package/packages/parser/tests/fixtures/multi-file.diff +22 -0
  40. package/packages/parser/tests/fixtures/new-file.diff +9 -0
  41. package/packages/parser/tests/fixtures/no-newline.diff +10 -0
  42. package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
  43. package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
  44. package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
  45. package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
  46. package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
  47. package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
  48. package/packages/parser/tests/fixtures/submodule.diff +7 -0
  49. package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
  50. package/packages/parser/tests/parse.test.ts +312 -0
  51. package/packages/parser/tests/word-diff-integration.test.ts +52 -0
  52. package/packages/parser/tests/word-diff.test.ts +121 -0
  53. package/packages/parser/tsconfig.json +10 -0
  54. package/packages/skills/diffity-resolve/SKILL.md +55 -0
  55. package/packages/skills/diffity-review/SKILL.md +74 -0
  56. package/packages/skills/diffity-start/SKILL.md +25 -0
  57. package/packages/ui/index.html +13 -0
  58. package/packages/ui/package.json +35 -0
  59. package/packages/ui/public/brand.svg +12 -0
  60. package/packages/ui/public/favicon.svg +15 -0
  61. package/packages/ui/src/app.tsx +14 -0
  62. package/packages/ui/src/components/comment-bubble.tsx +78 -0
  63. package/packages/ui/src/components/comment-form-row.tsx +58 -0
  64. package/packages/ui/src/components/comment-form.tsx +78 -0
  65. package/packages/ui/src/components/comment-line-number.tsx +60 -0
  66. package/packages/ui/src/components/comment-thread.tsx +209 -0
  67. package/packages/ui/src/components/commit-list.tsx +100 -0
  68. package/packages/ui/src/components/dashboard.tsx +84 -0
  69. package/packages/ui/src/components/diff-line.tsx +90 -0
  70. package/packages/ui/src/components/diff-page.tsx +332 -0
  71. package/packages/ui/src/components/diff-stats.tsx +20 -0
  72. package/packages/ui/src/components/diff-view.tsx +278 -0
  73. package/packages/ui/src/components/expand-row.tsx +45 -0
  74. package/packages/ui/src/components/file-block.tsx +536 -0
  75. package/packages/ui/src/components/file-tree-item.tsx +84 -0
  76. package/packages/ui/src/components/file-tree.tsx +72 -0
  77. package/packages/ui/src/components/general-comments.tsx +174 -0
  78. package/packages/ui/src/components/hunk-block-split.tsx +357 -0
  79. package/packages/ui/src/components/hunk-block.tsx +161 -0
  80. package/packages/ui/src/components/hunk-header.tsx +144 -0
  81. package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
  82. package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
  83. package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
  84. package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
  85. package/packages/ui/src/components/icons/check-icon.tsx +9 -0
  86. package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
  87. package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
  88. package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
  89. package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
  90. package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
  91. package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
  92. package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
  93. package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
  94. package/packages/ui/src/components/icons/file-icon.tsx +7 -0
  95. package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
  96. package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
  97. package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
  98. package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
  99. package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
  100. package/packages/ui/src/components/icons/search-icon.tsx +10 -0
  101. package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
  102. package/packages/ui/src/components/icons/spinner.tsx +7 -0
  103. package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
  104. package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
  105. package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
  106. package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
  107. package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
  108. package/packages/ui/src/components/icons/x-icon.tsx +10 -0
  109. package/packages/ui/src/components/line-number-cell.tsx +18 -0
  110. package/packages/ui/src/components/markdown-content.tsx +139 -0
  111. package/packages/ui/src/components/orphaned-threads.tsx +80 -0
  112. package/packages/ui/src/components/overview-file-list.tsx +57 -0
  113. package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
  114. package/packages/ui/src/components/shortcut-modal.tsx +93 -0
  115. package/packages/ui/src/components/sidebar.tsx +80 -0
  116. package/packages/ui/src/components/skeleton.tsx +9 -0
  117. package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
  118. package/packages/ui/src/components/summary-bar.tsx +39 -0
  119. package/packages/ui/src/components/toolbar.tsx +246 -0
  120. package/packages/ui/src/components/ui/badge.tsx +17 -0
  121. package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
  122. package/packages/ui/src/components/ui/icon-button.tsx +23 -0
  123. package/packages/ui/src/components/ui/status-badge.tsx +57 -0
  124. package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
  125. package/packages/ui/src/components/word-diff.tsx +126 -0
  126. package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
  127. package/packages/ui/src/hooks/use-commits.ts +12 -0
  128. package/packages/ui/src/hooks/use-copy.ts +18 -0
  129. package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
  130. package/packages/ui/src/hooks/use-diff.ts +12 -0
  131. package/packages/ui/src/hooks/use-highlighter.ts +190 -0
  132. package/packages/ui/src/hooks/use-info.ts +12 -0
  133. package/packages/ui/src/hooks/use-keyboard.ts +55 -0
  134. package/packages/ui/src/hooks/use-line-selection.ts +157 -0
  135. package/packages/ui/src/hooks/use-overview.ts +12 -0
  136. package/packages/ui/src/hooks/use-review-threads.ts +12 -0
  137. package/packages/ui/src/hooks/use-search-params.ts +26 -0
  138. package/packages/ui/src/hooks/use-theme.ts +34 -0
  139. package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
  140. package/packages/ui/src/lib/api.ts +232 -0
  141. package/packages/ui/src/lib/cn.ts +6 -0
  142. package/packages/ui/src/lib/context-expansion.ts +122 -0
  143. package/packages/ui/src/lib/diff-utils.ts +268 -0
  144. package/packages/ui/src/lib/dom-utils.ts +13 -0
  145. package/packages/ui/src/lib/file-tree.ts +122 -0
  146. package/packages/ui/src/lib/query-client.ts +10 -0
  147. package/packages/ui/src/lib/render-content.tsx +23 -0
  148. package/packages/ui/src/lib/syntax-token.ts +4 -0
  149. package/packages/ui/src/main.tsx +14 -0
  150. package/packages/ui/src/queries/commits.ts +9 -0
  151. package/packages/ui/src/queries/diff.ts +9 -0
  152. package/packages/ui/src/queries/file.ts +10 -0
  153. package/packages/ui/src/queries/info.ts +9 -0
  154. package/packages/ui/src/queries/overview.ts +9 -0
  155. package/packages/ui/src/styles/app.css +178 -0
  156. package/packages/ui/src/types/comment.ts +61 -0
  157. package/packages/ui/src/vite-env.d.ts +1 -0
  158. package/packages/ui/tests/context-expansion.test.ts +279 -0
  159. package/packages/ui/tests/diff-utils.test.ts +409 -0
  160. package/packages/ui/tsconfig.json +14 -0
  161. package/packages/ui/vite.config.ts +23 -0
  162. package/scripts/build-skills.ts +26 -0
  163. package/scripts/build.ts +15 -0
  164. package/scripts/dev.ts +32 -0
  165. package/scripts/lib/transformers/claude-code.ts +11 -0
  166. package/scripts/lib/transformers/codex.ts +17 -0
  167. package/scripts/lib/transformers/cursor.ts +17 -0
  168. package/scripts/lib/transformers/index.ts +3 -0
  169. package/scripts/lib/utils.ts +70 -0
  170. package/scripts/link-dev.ts +54 -0
  171. package/skills/diffity-resolve/SKILL.md +55 -0
  172. package/skills/diffity-review/SKILL.md +74 -0
  173. package/skills/diffity-start/SKILL.md +27 -0
  174. package/tsconfig.json +22 -0
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { fetchDiffFingerprint } from '../lib/api';
3
+
4
+ const POLL_INTERVAL = 3000;
5
+
6
+ export function useDiffStaleness(ref?: string, enabled = true) {
7
+ const [isStale, setIsStale] = useState(false);
8
+ const baselineRef = useRef<string | null>(null);
9
+
10
+ function resetStaleness() {
11
+ baselineRef.current = null;
12
+ setIsStale(false);
13
+ }
14
+
15
+ useEffect(() => {
16
+ if (!enabled) {
17
+ return;
18
+ }
19
+
20
+ let timer: ReturnType<typeof setTimeout>;
21
+ let cancelled = false;
22
+
23
+ async function poll() {
24
+ if (cancelled) {
25
+ return;
26
+ }
27
+
28
+ try {
29
+ const fingerprint = await fetchDiffFingerprint(ref);
30
+
31
+ if (cancelled) {
32
+ return;
33
+ }
34
+
35
+ if (baselineRef.current === null) {
36
+ baselineRef.current = fingerprint;
37
+ } else if (fingerprint !== baselineRef.current) {
38
+ setIsStale(true);
39
+ }
40
+ } catch {
41
+ // ignore fetch errors
42
+ }
43
+
44
+ if (!cancelled) {
45
+ timer = setTimeout(poll, POLL_INTERVAL);
46
+ }
47
+ }
48
+
49
+ poll();
50
+
51
+ return () => {
52
+ cancelled = true;
53
+ clearTimeout(timer);
54
+ };
55
+ }, [ref, enabled]);
56
+
57
+ return { isStale, resetStaleness };
58
+ }
@@ -0,0 +1,12 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { diffOptions } from '../queries/diff';
3
+
4
+ export function useDiff(hideWhitespace = false, ref?: string) {
5
+ const { data, isLoading, error } = useQuery(diffOptions(hideWhitespace, ref));
6
+
7
+ return {
8
+ data: data ?? null,
9
+ loading: isLoading,
10
+ error: error?.message ?? null,
11
+ };
12
+ }
@@ -0,0 +1,190 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { createHighlighter, type Highlighter, type BundledLanguage } from 'shiki';
3
+
4
+ const LANG_MAP: Record<string, BundledLanguage> = {
5
+ ts: 'typescript',
6
+ tsx: 'tsx',
7
+ js: 'javascript',
8
+ jsx: 'jsx',
9
+ json: 'json',
10
+ css: 'css',
11
+ html: 'html',
12
+ md: 'markdown',
13
+ mdx: 'mdx',
14
+ py: 'python',
15
+ rb: 'ruby',
16
+ rs: 'rust',
17
+ go: 'go',
18
+ java: 'java',
19
+ sh: 'bash',
20
+ bash: 'bash',
21
+ zsh: 'bash',
22
+ fish: 'fish',
23
+ ps1: 'powershell',
24
+ yml: 'yaml',
25
+ yaml: 'yaml',
26
+ xml: 'xml',
27
+ svg: 'xml',
28
+ sql: 'sql',
29
+ graphql: 'graphql',
30
+ gql: 'graphql',
31
+ dockerfile: 'dockerfile',
32
+ toml: 'toml',
33
+ ini: 'ini',
34
+ lua: 'lua',
35
+ c: 'c',
36
+ cpp: 'cpp',
37
+ cc: 'cpp',
38
+ cxx: 'cpp',
39
+ h: 'c',
40
+ hpp: 'cpp',
41
+ cs: 'csharp',
42
+ swift: 'swift',
43
+ kt: 'kotlin',
44
+ kts: 'kotlin',
45
+ scala: 'scala',
46
+ vue: 'vue',
47
+ svelte: 'svelte',
48
+ php: 'php',
49
+ r: 'r',
50
+ scss: 'scss',
51
+ less: 'less',
52
+ sass: 'sass',
53
+ styl: 'stylus',
54
+ dart: 'dart',
55
+ ex: 'elixir',
56
+ exs: 'elixir',
57
+ erl: 'erlang',
58
+ hs: 'haskell',
59
+ clj: 'clojure',
60
+ cljs: 'clojure',
61
+ pl: 'perl',
62
+ pm: 'perl',
63
+ zig: 'zig',
64
+ nim: 'nim',
65
+ ml: 'ocaml',
66
+ mli: 'ocaml',
67
+ fs: 'fsharp',
68
+ fsx: 'fsharp',
69
+ groovy: 'groovy',
70
+ gradle: 'groovy',
71
+ tf: 'hcl',
72
+ hcl: 'hcl',
73
+ proto: 'protobuf',
74
+ prisma: 'prisma',
75
+ astro: 'astro',
76
+ m: 'objective-c',
77
+ mm: 'objective-cpp',
78
+ tex: 'latex',
79
+ latex: 'latex',
80
+ diff: 'diff',
81
+ patch: 'diff',
82
+ nginx: 'nginx',
83
+ conf: 'ini',
84
+ cfg: 'ini',
85
+ env: 'dotenv',
86
+ bat: 'bat',
87
+ cmd: 'bat',
88
+ asm: 'asm',
89
+ s: 'asm',
90
+ jsonc: 'jsonc',
91
+ json5: 'json5',
92
+ csv: 'csv',
93
+ tsv: 'csv',
94
+ wasm: 'wasm',
95
+ ejs: 'html',
96
+ hbs: 'handlebars',
97
+ pug: 'pug',
98
+ jade: 'pug',
99
+ rst: 'rst',
100
+ jl: 'julia',
101
+ v: 'v',
102
+ sol: 'solidity',
103
+ glsl: 'glsl',
104
+ hlsl: 'hlsl',
105
+ wgsl: 'wgsl',
106
+ };
107
+
108
+ const FILENAME_MAP: Record<string, BundledLanguage> = {
109
+ dockerfile: 'dockerfile',
110
+ makefile: 'makefile',
111
+ cmakelists: 'cmake',
112
+ gemfile: 'ruby',
113
+ rakefile: 'ruby',
114
+ justfile: 'just',
115
+ vagrantfile: 'ruby',
116
+ };
117
+
118
+ function getLang(filePath: string): BundledLanguage | null {
119
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
120
+ const fileName = filePath.split('/').pop()?.toLowerCase() || '';
121
+
122
+ const fileNameMatch = FILENAME_MAP[fileName];
123
+ if (fileNameMatch) {
124
+ return fileNameMatch;
125
+ }
126
+
127
+ return LANG_MAP[ext] || null;
128
+ }
129
+
130
+ const ALL_LANGS: BundledLanguage[] = [
131
+ ...new Set([
132
+ ...Object.values(LANG_MAP),
133
+ ...Object.values(FILENAME_MAP),
134
+ ]),
135
+ ];
136
+
137
+ let highlighterPromise: Promise<Highlighter> | null = null;
138
+
139
+ function getHighlighter(): Promise<Highlighter> {
140
+ if (!highlighterPromise) {
141
+ highlighterPromise = createHighlighter({
142
+ themes: ['github-light', 'github-dark'],
143
+ langs: ALL_LANGS,
144
+ });
145
+ }
146
+ return highlighterPromise;
147
+ }
148
+
149
+ export interface HighlightedTokens {
150
+ tokens: { text: string; color?: string }[];
151
+ }
152
+
153
+ export function useHighlighter() {
154
+ const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
155
+
156
+ useEffect(() => {
157
+ getHighlighter().then(setHighlighter);
158
+ }, []);
159
+
160
+ const highlight = useCallback((code: string, filePath: string, theme: 'light' | 'dark'): HighlightedTokens[] | null => {
161
+ if (!highlighter) {
162
+ return null;
163
+ }
164
+
165
+ const lang = getLang(filePath);
166
+ if (!lang) {
167
+ return null;
168
+ }
169
+
170
+ const shikiTheme = theme === 'dark' ? 'github-dark' : 'github-light';
171
+
172
+ try {
173
+ const result = highlighter.codeToTokens(code, {
174
+ lang,
175
+ theme: shikiTheme,
176
+ });
177
+
178
+ return result.tokens.map(line => ({
179
+ tokens: line.map(token => ({
180
+ text: token.content,
181
+ color: token.color,
182
+ })),
183
+ }));
184
+ } catch {
185
+ return null;
186
+ }
187
+ }, [highlighter]);
188
+
189
+ return { highlight, ready: highlighter !== null };
190
+ }
@@ -0,0 +1,12 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { repoInfoOptions } from '../queries/info';
3
+
4
+ export function useInfo(ref?: string) {
5
+ const { data, isLoading, error } = useQuery(repoInfoOptions(ref));
6
+
7
+ return {
8
+ data: data ?? null,
9
+ loading: isLoading,
10
+ error: error?.message ?? null,
11
+ };
12
+ }
@@ -0,0 +1,55 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useHotkeys } from 'react-hotkeys-hook';
3
+
4
+ interface KeyboardActions {
5
+ onNextFile: () => void;
6
+ onPrevFile: () => void;
7
+ onNextHunk: () => void;
8
+ onPrevHunk: () => void;
9
+ onToggleCollapse: () => void;
10
+ onCollapseAll: () => void;
11
+ onToggleReviewed: () => void;
12
+ onUnifiedView: () => void;
13
+ onSplitView: () => void;
14
+ onShowHelp: () => void;
15
+ onFocusSearch: () => void;
16
+ onEscape: () => void;
17
+ }
18
+
19
+ const HOTKEY_OPTIONS = { preventDefault: true };
20
+
21
+ function isInputFocused(): boolean {
22
+ const tag = document.activeElement?.tagName;
23
+ return tag === 'INPUT' || tag === 'TEXTAREA';
24
+ }
25
+
26
+ export function useKeyboard(actions: KeyboardActions) {
27
+ const actionsRef = useRef(actions);
28
+ actionsRef.current = actions;
29
+
30
+ useHotkeys('j', actions.onNextFile, HOTKEY_OPTIONS);
31
+ useHotkeys('k', actions.onPrevFile, HOTKEY_OPTIONS);
32
+ useHotkeys('n', actions.onNextHunk, HOTKEY_OPTIONS);
33
+ useHotkeys('p', actions.onPrevHunk, HOTKEY_OPTIONS);
34
+ useHotkeys('x', actions.onToggleCollapse, HOTKEY_OPTIONS);
35
+ useHotkeys('shift+x', actions.onCollapseAll, HOTKEY_OPTIONS);
36
+ useHotkeys('r', actions.onToggleReviewed, HOTKEY_OPTIONS);
37
+ useHotkeys('u', actions.onUnifiedView, HOTKEY_OPTIONS);
38
+ useHotkeys('s', actions.onSplitView, HOTKEY_OPTIONS);
39
+ useHotkeys('escape', actions.onEscape, { enableOnFormTags: ['INPUT', 'TEXTAREA'] });
40
+
41
+ useEffect(() => {
42
+ const handleKeyDown = (e: KeyboardEvent) => {
43
+ if (e.key === '/' && !isInputFocused()) {
44
+ e.preventDefault();
45
+ actionsRef.current.onFocusSearch();
46
+ }
47
+ if (e.key === '?' && !isInputFocused()) {
48
+ e.preventDefault();
49
+ actionsRef.current.onShowHelp();
50
+ }
51
+ };
52
+ window.addEventListener('keydown', handleKeyDown);
53
+ return () => window.removeEventListener('keydown', handleKeyDown);
54
+ }, []);
55
+ }
@@ -0,0 +1,157 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import type { CommentSide, LineSelection } from '../types/comment';
3
+
4
+ interface UseLineSelectionOptions {
5
+ filePath: string;
6
+ onSelectionComplete: (selection: LineSelection) => void;
7
+ }
8
+
9
+ interface UseLineSelectionReturn {
10
+ selectionState: {
11
+ side: CommentSide;
12
+ anchorLine: number;
13
+ currentLine: number;
14
+ } | null;
15
+ handleLineMouseDown: (line: number, side: CommentSide) => void;
16
+ handleLineMouseEnter: (line: number, side: CommentSide) => void;
17
+ isLineInSelection: (line: number, side: CommentSide) => boolean;
18
+ getSelectionRange: () => { startLine: number; endLine: number; side: CommentSide } | null;
19
+ }
20
+
21
+ function getLineNumberFromPoint(x: number, y: number): number | null {
22
+ const el = document.elementFromPoint(x, y);
23
+ if (!el) {
24
+ return null;
25
+ }
26
+
27
+ const td = el.closest('td');
28
+ if (!td) {
29
+ return null;
30
+ }
31
+
32
+ const text = td.textContent?.trim();
33
+ if (!text) {
34
+ return null;
35
+ }
36
+
37
+ const num = parseInt(text, 10);
38
+ if (isNaN(num)) {
39
+ return null;
40
+ }
41
+
42
+ return num;
43
+ }
44
+
45
+ export function useLineSelection(options: UseLineSelectionOptions): UseLineSelectionReturn {
46
+ const { filePath, onSelectionComplete } = options;
47
+ const [selectionState, setSelectionState] = useState<{
48
+ side: CommentSide;
49
+ anchorLine: number;
50
+ currentLine: number;
51
+ } | null>(null);
52
+ const isDragging = useRef(false);
53
+ const anchorX = useRef(0);
54
+ const selectionRef = useRef(selectionState);
55
+ selectionRef.current = selectionState;
56
+
57
+ const handleLineMouseDown = useCallback((line: number, side: CommentSide) => {
58
+ isDragging.current = true;
59
+ anchorX.current = 0;
60
+ setSelectionState({ side, anchorLine: line, currentLine: line });
61
+ }, []);
62
+
63
+ const handleLineMouseEnter = useCallback((line: number, side: CommentSide) => {
64
+ if (!isDragging.current || !selectionRef.current) {
65
+ return;
66
+ }
67
+ if (side !== selectionRef.current.side) {
68
+ return;
69
+ }
70
+ setSelectionState(prev => {
71
+ if (!prev) {
72
+ return prev;
73
+ }
74
+ return { ...prev, currentLine: line };
75
+ });
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ const handleMouseMove = (e: MouseEvent) => {
80
+ if (!isDragging.current) {
81
+ return;
82
+ }
83
+
84
+ if (anchorX.current === 0) {
85
+ anchorX.current = e.clientX;
86
+ }
87
+
88
+ const lineNum = getLineNumberFromPoint(anchorX.current, e.clientY);
89
+ if (lineNum !== null) {
90
+ setSelectionState(prev => {
91
+ if (!prev || prev.currentLine === lineNum) {
92
+ return prev;
93
+ }
94
+ return { ...prev, currentLine: lineNum };
95
+ });
96
+ }
97
+ };
98
+
99
+ const handleMouseUp = () => {
100
+ const state = selectionRef.current;
101
+ if (!isDragging.current || !state) {
102
+ isDragging.current = false;
103
+ return;
104
+ }
105
+ isDragging.current = false;
106
+
107
+ const startLine = Math.min(state.anchorLine, state.currentLine);
108
+ const endLine = Math.max(state.anchorLine, state.currentLine);
109
+
110
+ onSelectionComplete({
111
+ filePath,
112
+ side: state.side,
113
+ startLine,
114
+ endLine,
115
+ });
116
+ setSelectionState(null);
117
+ };
118
+
119
+ window.addEventListener('mousemove', handleMouseMove);
120
+ window.addEventListener('mouseup', handleMouseUp);
121
+ return () => {
122
+ window.removeEventListener('mousemove', handleMouseMove);
123
+ window.removeEventListener('mouseup', handleMouseUp);
124
+ };
125
+ }, [filePath, onSelectionComplete]);
126
+
127
+ const isLineInSelection = useCallback((line: number, side: CommentSide) => {
128
+ if (!selectionState) {
129
+ return false;
130
+ }
131
+ if (side !== selectionState.side) {
132
+ return false;
133
+ }
134
+ const start = Math.min(selectionState.anchorLine, selectionState.currentLine);
135
+ const end = Math.max(selectionState.anchorLine, selectionState.currentLine);
136
+ return line >= start && line <= end;
137
+ }, [selectionState]);
138
+
139
+ const getSelectionRange = useCallback(() => {
140
+ if (!selectionState) {
141
+ return null;
142
+ }
143
+ return {
144
+ startLine: Math.min(selectionState.anchorLine, selectionState.currentLine),
145
+ endLine: Math.max(selectionState.anchorLine, selectionState.currentLine),
146
+ side: selectionState.side,
147
+ };
148
+ }, [selectionState]);
149
+
150
+ return {
151
+ selectionState,
152
+ handleLineMouseDown,
153
+ handleLineMouseEnter,
154
+ isLineInSelection,
155
+ getSelectionRange,
156
+ };
157
+ }
@@ -0,0 +1,12 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { overviewOptions } from '../queries/overview';
3
+
4
+ export function useOverview() {
5
+ const { data, isLoading, error } = useQuery(overviewOptions());
6
+
7
+ return {
8
+ data: data ?? null,
9
+ loading: isLoading,
10
+ error: error?.message ?? null,
11
+ };
12
+ }
@@ -0,0 +1,12 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { fetchThreads } from '../lib/api';
3
+ import type { CommentThread } from '../types/comment';
4
+
5
+ export function useReviewThreads(sessionId: string | null | undefined) {
6
+ return useQuery<CommentThread[]>({
7
+ queryKey: ['threads', sessionId],
8
+ queryFn: () => fetchThreads(sessionId!),
9
+ enabled: !!sessionId,
10
+ refetchInterval: 2000,
11
+ });
12
+ }
@@ -0,0 +1,26 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ function getParams() {
4
+ const params = new URLSearchParams(window.location.search);
5
+ return {
6
+ ref: params.get('ref'),
7
+ theme: params.get('theme') as 'light' | 'dark' | null,
8
+ view: params.get('view') as 'split' | 'unified' | null,
9
+ };
10
+ }
11
+
12
+ export function useSearchParams() {
13
+ const [values, setValues] = useState(getParams);
14
+
15
+ useEffect(() => {
16
+ const handler = () => {
17
+ setValues(getParams());
18
+ };
19
+ window.addEventListener('popstate', handler);
20
+ return () => {
21
+ window.removeEventListener('popstate', handler);
22
+ };
23
+ }, []);
24
+
25
+ return values;
26
+ }
@@ -0,0 +1,34 @@
1
+ import { useState, useEffect, useLayoutEffect, useCallback } from 'react';
2
+
3
+ type Theme = 'light' | 'dark';
4
+
5
+ function getStoredTheme(): Theme | null {
6
+ if (typeof window === 'undefined') {
7
+ return null;
8
+ }
9
+ return localStorage.getItem('diffity-theme') as Theme | null;
10
+ }
11
+
12
+ export function getTheme(): Theme {
13
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
14
+ }
15
+
16
+ export function useTheme(initialTheme?: Theme | null) {
17
+ const [theme, setTheme] = useState<Theme>(
18
+ () => getStoredTheme() || initialTheme || 'light'
19
+ );
20
+
21
+ useLayoutEffect(() => {
22
+ document.documentElement.setAttribute('data-theme', theme);
23
+ }, [theme]);
24
+
25
+ const toggleTheme = useCallback(() => {
26
+ setTheme(prev => {
27
+ const next = prev === 'light' ? 'dark' : 'light';
28
+ localStorage.setItem('diffity-theme', next);
29
+ return next;
30
+ });
31
+ }, []);
32
+
33
+ return { theme, toggleTheme };
34
+ }
@@ -0,0 +1,43 @@
1
+ import { useState, useCallback, useMemo } from 'react';
2
+ import type { CommentThread } from '../types/comment';
3
+ import { isThreadResolved } from '../types/comment';
4
+
5
+ export function useThreadNavigation(threads: CommentThread[], onScrollToThread: (threadId: string, filePath: string) => void) {
6
+ const [currentIndex, setCurrentIndex] = useState(-1);
7
+
8
+ const unresolvedThreads = useMemo(() => threads.filter(t => !isThreadResolved(t)), [threads]);
9
+ const count = unresolvedThreads.length;
10
+
11
+ const scrollToThread = useCallback((index: number) => {
12
+ const thread = unresolvedThreads[index];
13
+ if (!thread) {
14
+ return;
15
+ }
16
+ onScrollToThread(thread.id, thread.filePath);
17
+ }, [unresolvedThreads, onScrollToThread]);
18
+
19
+ const goToPrevious = useCallback(() => {
20
+ if (count === 0) {
21
+ return;
22
+ }
23
+ const nextIndex = currentIndex <= 0 ? count - 1 : currentIndex - 1;
24
+ setCurrentIndex(nextIndex);
25
+ scrollToThread(nextIndex);
26
+ }, [currentIndex, count, scrollToThread]);
27
+
28
+ const goToNext = useCallback(() => {
29
+ if (count === 0) {
30
+ return;
31
+ }
32
+ const nextIndex = currentIndex >= count - 1 ? 0 : currentIndex + 1;
33
+ setCurrentIndex(nextIndex);
34
+ scrollToThread(nextIndex);
35
+ }, [currentIndex, count, scrollToThread]);
36
+
37
+ return {
38
+ currentIndex,
39
+ count,
40
+ goToPrevious,
41
+ goToNext,
42
+ };
43
+ }