feat: pierre diffs lib (#147)

This commit is contained in:
Carlos Florêncio
2026-02-02 00:17:23 +00:00
committed by GitHub
parent dfe2cc3263
commit 38fb2f638f
44 changed files with 2371 additions and 1962 deletions
@@ -224,13 +224,12 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo
})
} else {
// str_replace operation
// Only include the diff (not old/new content) to reduce payload size
oldStr, _ := rawInput["old_str_1"].(string)
newStr, _ := rawInput["new_str_1"].(string)
mutation := streams.FileMutation{
Type: streams.MutationPatch,
OldContent: oldStr,
NewContent: newStr,
Type: streams.MutationPatch,
}
// Add line numbers if available
@@ -241,8 +240,8 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo
mutation.EndLine = int(endLine)
}
// Generate unified diff
if oldStr != "" && newStr != "" {
// Generate unified diff when at least one string is provided
if oldStr != "" || newStr != "" {
mutation.Diff = shared.GenerateUnifiedDiff(oldStr, newStr, path, mutation.StartLine)
}
@@ -204,17 +204,37 @@ func TestDetectLanguage(t *testing.T) {
// TestGenerateUnifiedDiff tests the unified diff generation.
func TestGenerateUnifiedDiff(t *testing.T) {
t.Run("returns empty when old is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1)
t.Run("returns empty when both are empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "", "file.ts", 1)
if result != "" {
t.Errorf("expected empty string for empty old string, got %q", result)
t.Errorf("expected empty string when both old and new are empty, got %q", result)
}
})
t.Run("returns empty when new is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1)
t.Run("returns empty when old equals new", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("same content", "same content", "file.ts", 1)
if result != "" {
t.Errorf("expected empty string for empty new string, got %q", result)
t.Errorf("expected empty string when old equals new, got %q", result)
}
})
t.Run("generates all-additions diff when old is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1)
if result == "" {
t.Fatal("expected non-empty diff for create operation")
}
if !strings.Contains(result, "+new content") {
t.Error("expected added line in diff")
}
})
t.Run("generates all-deletions diff when new is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1)
if result == "" {
t.Fatal("expected non-empty diff for delete operation")
}
if !strings.Contains(result, "-old content") {
t.Error("expected removed line in diff")
}
})
@@ -284,11 +304,9 @@ func TestNormalizerEdit(t *testing.T) {
t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations))
}
mutation := result.ModifyFile().Mutations[0]
if mutation.OldContent != "old code" {
t.Errorf("expected OldContent 'old code', got %q", mutation.OldContent)
}
if mutation.NewContent != "new code" {
t.Errorf("expected NewContent 'new code', got %q", mutation.NewContent)
// OldContent and NewContent are no longer set (only Diff is generated)
if mutation.Diff == "" {
t.Error("expected Diff to be generated")
}
if mutation.StartLine != 10 {
t.Errorf("expected StartLine 10, got %d", mutation.StartLine)
@@ -9,7 +9,11 @@ import (
// GenerateUnifiedDiff creates a unified diff string from old and new content.
func GenerateUnifiedDiff(oldStr, newStr, path string, startLine int) string {
if oldStr == "" || newStr == "" {
// If both empty or identical, no diff needed
if oldStr == "" && newStr == "" {
return ""
}
if oldStr == newStr {
return ""
}
@@ -106,14 +106,13 @@ func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *stream
})
} else {
// Edit is a patch operation
// Only include the diff (not old/new content) to reduce payload size
mutation := streams.FileMutation{
Type: streams.MutationPatch,
OldContent: oldString,
NewContent: newString,
Type: streams.MutationPatch,
}
// Generate unified diff
if oldString != "" && newString != "" {
// Generate unified diff when at least one string is provided
if oldString != "" || newString != "" {
mutation.Diff = shared.GenerateUnifiedDiff(oldString, newString, filePath, 0)
}
@@ -122,17 +122,37 @@ func TestDetectLanguage(t *testing.T) {
// TestGenerateUnifiedDiff tests the unified diff generation.
func TestGenerateUnifiedDiff(t *testing.T) {
t.Run("returns empty when old is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1)
t.Run("returns empty when both are empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "", "file.ts", 1)
if result != "" {
t.Errorf("expected empty string for empty old string, got %q", result)
t.Errorf("expected empty string when both old and new are empty, got %q", result)
}
})
t.Run("returns empty when new is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1)
t.Run("returns empty when old equals new", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("same content", "same content", "file.ts", 1)
if result != "" {
t.Errorf("expected empty string for empty new string, got %q", result)
t.Errorf("expected empty string when old equals new, got %q", result)
}
})
t.Run("generates all-additions diff when old is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1)
if result == "" {
t.Fatal("expected non-empty diff for create operation")
}
if !strings.Contains(result, "+new content") {
t.Error("expected added line in diff")
}
})
t.Run("generates all-deletions diff when new is empty", func(t *testing.T) {
result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1)
if result == "" {
t.Fatal("expected non-empty diff for delete operation")
}
if !strings.Contains(result, "-old content") {
t.Error("expected removed line in diff")
}
})
@@ -196,11 +216,9 @@ func TestNormalizerEdit(t *testing.T) {
t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations))
}
mutation := result.ModifyFile().Mutations[0]
if mutation.OldContent != "const x = 1;" {
t.Errorf("expected OldContent 'const x = 1;', got %q", mutation.OldContent)
}
if mutation.NewContent != "const x = 2;" {
t.Errorf("expected NewContent 'const x = 2;', got %q", mutation.NewContent)
// OldContent and NewContent are no longer set (only Diff is generated)
if mutation.Diff == "" {
t.Error("expected Diff to be generated")
}
})
@@ -219,7 +237,7 @@ func TestNormalizerEdit(t *testing.T) {
}
})
t.Run("no diff when old_string is empty", func(t *testing.T) {
t.Run("generates diff when old_string is empty", func(t *testing.T) {
args := map[string]any{
"file_path": "/workspace/new.ts",
"old_string": "",
@@ -229,8 +247,8 @@ func TestNormalizerEdit(t *testing.T) {
if result.ModifyFile() == nil || len(result.ModifyFile().Mutations) == 0 {
t.Fatal("expected mutation")
}
if result.ModifyFile().Mutations[0].Diff != "" {
t.Error("expected no diff when old_string is empty")
if result.ModifyFile().Mutations[0].Diff == "" {
t.Error("expected diff to be generated when old_string is empty")
}
})
+159 -100
View File
@@ -100,7 +100,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
tailwindcss:
specifier: ^4.0.0
version: 4.1.18
@@ -285,12 +285,6 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
'@git-diff-view/file':
specifier: ^0.0.36
version: 0.0.36
'@git-diff-view/react':
specifier: ^0.0.36
version: 0.0.36(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@kandev/theme':
specifier: workspace:*
version: link:../packages/theme
@@ -330,6 +324,9 @@ importers:
'@milkdown/utils':
specifier: ^7.18.0
version: 7.18.0
'@pierre/diffs':
specifier: ^1.0.10
version: 1.0.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tabler/icons-react':
specifier: ^3.36.1
version: 3.36.1(react@19.2.3)
@@ -453,7 +450,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
happy-dom:
specifier: ^20.1.0
version: 20.1.0
@@ -1136,21 +1133,6 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@git-diff-view/core@0.0.36':
resolution: {integrity: sha512-A3mN21C/ZdIJz0J9/PSZvLR1dLBu8BG13tVE+w4A9v8dLS5omkoQVjHM9K5GaoJideMhK/11y6V7CMbMXCPQoQ==}
'@git-diff-view/file@0.0.36':
resolution: {integrity: sha512-3I5GMpZzdTwSurzbz8ZT9HL2xo74W5/o/SgWhUpEWjn2Z6pCZrHLAEzWeYyqewv30YM25PuK/JMODT4S5zhvzw==}
'@git-diff-view/lowlight@0.0.36':
resolution: {integrity: sha512-8H3fUfnW+jw8EEbhEOr0vq5jXxZfbhNgvf9ruY6GljQdymj8j2R69k83gwMB/GltWEMt5o5624d8MxD21vqsZA==}
'@git-diff-view/react@0.0.36':
resolution: {integrity: sha512-RqM4vkU2uoiav8di5LavR8UDkSP+fNDftlNOfAMQmcuLDFpDnLhtqcgot4+Q5rflkwK04UlYL6G72ps9XSGgDw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@hono/node-server@1.19.8':
resolution: {integrity: sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==}
engines: {node: '>=18.14.1'}
@@ -1632,6 +1614,12 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@pierre/diffs@1.0.10':
resolution: {integrity: sha512-ahkpfS30NfaB+PBxnf0/Mc20ySBRTQmM28a7Ojpd0UZixmTyhGhJfBFjvmhX8dSzR22lB3h3OIMMxpB4yYTIOQ==}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2501,6 +2489,30 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
'@shikijs/transformers@3.22.0':
resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -3016,9 +3028,6 @@ packages:
'@vue/compiler-ssr@3.5.27':
resolution: {integrity: sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==}
'@vue/reactivity@3.5.26':
resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==}
'@vue/reactivity@3.5.27':
resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==}
@@ -3033,9 +3042,6 @@ packages:
peerDependencies:
vue: 3.5.27
'@vue/shared@3.5.26':
resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==}
'@vue/shared@3.5.27':
resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==}
@@ -4017,9 +4023,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
@@ -4251,6 +4254,9 @@ packages:
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@@ -4266,10 +4272,6 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.11.3:
resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==}
engines: {node: '>=16.9.0'}
@@ -4277,6 +4279,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -4761,12 +4766,12 @@ packages:
loupe@2.3.7:
resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -5161,6 +5166,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
@@ -5489,11 +5500,6 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
reactivity-store@0.3.12:
resolution: {integrity: sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@@ -5526,6 +5532,15 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@@ -5696,6 +5711,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -7161,39 +7179,6 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@git-diff-view/core@0.0.36':
dependencies:
'@git-diff-view/lowlight': 0.0.36
fast-diff: 1.3.0
highlight.js: 11.11.1
lowlight: 3.3.0
'@git-diff-view/file@0.0.36':
dependencies:
'@git-diff-view/core': 0.0.36
diff: 8.0.3
fast-diff: 1.3.0
highlight.js: 11.11.1
lowlight: 3.3.0
'@git-diff-view/lowlight@0.0.36':
dependencies:
'@types/hast': 3.0.4
highlight.js: 11.11.1
lowlight: 3.3.0
'@git-diff-view/react@0.0.36(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@git-diff-view/core': 0.0.36
'@types/hast': 3.0.4
fast-diff: 1.3.0
highlight.js: 11.11.1
lowlight: 3.3.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
reactivity-store: 0.3.12(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
'@hono/node-server@1.19.8(hono@4.11.3)':
dependencies:
hono: 4.11.3
@@ -7890,6 +7875,18 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@pierre/diffs@1.0.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/transformers': 3.22.0
diff: 8.0.3
hast-util-to-html: 9.0.5
lru_map: 0.4.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
shiki: 3.22.0
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -8750,6 +8747,44 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/themes@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/transformers@3.22.0':
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/types@3.22.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sinclair/typebox@0.27.8': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -9299,10 +9334,6 @@ snapshots:
'@vue/compiler-dom': 3.5.27
'@vue/shared': 3.5.27
'@vue/reactivity@3.5.26':
dependencies:
'@vue/shared': 3.5.26
'@vue/reactivity@3.5.27':
dependencies:
'@vue/shared': 3.5.27
@@ -9325,8 +9356,6 @@ snapshots:
'@vue/shared': 3.5.27
vue: 3.5.27(typescript@5.9.3)
'@vue/shared@3.5.26': {}
'@vue/shared@3.5.27': {}
'@xterm/addon-attach@0.12.0': {}
@@ -10617,8 +10646,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-equals@5.4.0: {}
fast-glob@3.3.1:
@@ -10866,6 +10893,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -10898,12 +10939,12 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
highlight.js@11.11.1: {}
hono@4.11.3: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -11317,16 +11358,12 @@ snapshots:
dependencies:
get-func-name: 2.0.2
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lru_map@0.4.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -12036,6 +12073,14 @@ snapshots:
dependencies:
mimic-function: 5.0.1
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.4:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
open@11.0.0:
dependencies:
default-browser: 5.4.0
@@ -12466,13 +12511,6 @@ snapshots:
react@19.2.3: {}
reactivity-store@0.3.12(react@19.2.3):
dependencies:
'@vue/reactivity': 3.5.26
'@vue/shared': 3.5.26
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@@ -12538,6 +12576,16 @@ snapshots:
get-proto: 1.0.1
which-builtin-type: 1.2.1
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
@@ -12880,6 +12928,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.22.0:
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/engine-oniguruma': 3.22.0
'@shikijs/langs': 3.22.0
'@shikijs/themes': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -1,181 +0,0 @@
'use client';
import { useState } from 'react';
import { ThemeProvider } from 'next-themes';
import { TooltipProvider } from '@kandev/ui/tooltip';
import { Button } from '@kandev/ui/button';
import { GitDiffViewer } from '@/components/git-diff-view';
// Sample diffs for demo
const BUTTON_DIFF_OLD = `import React from 'react';
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export function Button({ children, onClick, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}`;
const BUTTON_DIFF_NEW = `import React from 'react';
import { cn } from '../lib/utils';
interface ButtonProps {
children: React.ReactNode;
onClick?: (event: React.MouseEvent) => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({
children,
onClick,
variant = 'primary',
disabled
}: ButtonProps) {
return (
<button className={cn('btn', \`btn-\${variant}\`)} onClick={onClick} disabled={disabled}>
{children}
</button>
);
}`;
const FORMAT_DIFF_OLD = '';
const FORMAT_DIFF_NEW = `export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}`;
type DiffExample = 'button' | 'newfile';
const DIFFS: Record<
DiffExample,
{ label: string; oldContent: string; newContent: string; path: string; description: string }
> = {
button: {
label: 'Button Component',
oldContent: BUTTON_DIFF_OLD,
newContent: BUTTON_DIFF_NEW,
path: 'src/Button.tsx',
description: 'Standard diff with additions and deletions',
},
newfile: {
label: 'New File',
oldContent: FORMAT_DIFF_OLD,
newContent: FORMAT_DIFF_NEW,
path: 'src/utils/format.ts',
description: 'Shows a newly created file (all additions)',
},
};
function GitDiffViewDemoContent() {
const [selectedDiff, setSelectedDiff] = useState<DiffExample>('button');
const currentDiff = DIFFS[selectedDiff];
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-2xl font-bold">GitDiffViewer Demo</h1>
<p className="text-muted-foreground">
Diff viewer with inline comments and multi-line selection.
</p>
</div>
{/* Diff selector */}
<div className="flex flex-wrap gap-2">
{Object.entries(DIFFS).map(([key, { label }]) => (
<Button
key={key}
variant={selectedDiff === key ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedDiff(key as DiffExample)}
>
{label}
</Button>
))}
</div>
{/* Description */}
<div className="text-sm text-muted-foreground bg-muted/50 px-3 py-2 rounded">
{currentDiff.description}
</div>
{/* Instructions */}
<div className="text-xs text-muted-foreground bg-primary/5 border border-primary/20 px-3 py-2 rounded space-y-1">
<p>
<strong>Single line:</strong> Click the{' '}
<span className="font-mono bg-muted px-1 rounded">+</span> button on any line to add a
comment.
</p>
<p>
<strong>Multi-line:</strong> Click and drag from one line to another to select a range,
then add your comment.
</p>
</div>
{/* Diff viewer */}
<GitDiffViewer
oldContent={currentDiff.oldContent}
newContent={currentDiff.newContent}
filePath={currentDiff.path}
language="tsx"
defaultViewMode="split"
enableComments
/>
{/* Features list */}
<div className="p-4 bg-muted rounded-lg space-y-3 text-sm">
<h3 className="font-medium">Features:</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-muted-foreground mb-1">Built-in Features</h4>
<ul className="list-disc list-inside space-y-0.5 text-muted-foreground">
<li>Syntax highlighting</li>
<li>Split and unified view modes</li>
<li>Light and dark theme support</li>
<li>Add widget (+) buttons on hover</li>
</ul>
</div>
<div>
<h4 className="font-medium text-muted-foreground mb-1">Comments</h4>
<ul className="list-disc list-inside space-y-0.5 text-muted-foreground">
<li>Click + to open inline comment widget</li>
<li>Click and drag to select multiple lines</li>
<li>Visual overlay shows selected range</li>
<li>Comments persisted to localStorage</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
export function GitDiffViewDemo() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<GitDiffViewDemoContent />
</TooltipProvider>
</ThemeProvider>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { GitDiffViewDemo } from './demo-client';
export const metadata = {
title: 'GitDiffViewer Demo',
};
export default function GitDiffViewDemoPage() {
return <GitDiffViewDemo />;
}
@@ -0,0 +1,103 @@
'use client';
import { Button } from '@kandev/ui/button';
import { IconTrash, IconEdit, IconMessage } from '@tabler/icons-react';
import type { DiffComment } from '@/lib/diff/types';
import { formatLineRange } from '@/lib/diff';
interface CommentDisplayProps {
/** The comment to display */
comment: DiffComment;
/** Callback to delete the comment */
onDelete?: () => void;
/** Callback to edit the comment */
onEdit?: () => void;
/** Whether to show the code content */
showCode?: boolean;
/** Compact mode for inline display */
compact?: boolean;
}
export function CommentDisplay({
comment,
onDelete,
onEdit,
showCode = false,
compact = false,
}: CommentDisplayProps) {
const lineRange = formatLineRange(comment.startLine, comment.endLine);
if (compact) {
return (
<div className="group flex items-start gap-2 rounded border border-border bg-muted/50 px-2 py-1.5 text-xs">
<IconMessage className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground">{lineRange}</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="break-words">{comment.annotation}</span>
</div>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-4 w-4 shrink-0 cursor-pointer p-0 opacity-0 hover:text-destructive group-hover:opacity-100"
>
<IconTrash className="h-3 w-3" />
</Button>
)}
</div>
);
}
return (
<div className="group rounded-md border border-border bg-card p-2 shadow-sm">
{/* Header */}
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs">
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
<span className="font-medium">
{lineRange}
</span>
<span className="text-muted-foreground">
({comment.side === 'additions' ? 'new' : 'old'})
</span>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={onEdit}
className="h-5 w-5 cursor-pointer p-0"
>
<IconEdit className="h-3 w-3" />
</Button>
)}
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
>
<IconTrash className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Code preview */}
{showCode && comment.codeContent && (
<pre className="mb-2 overflow-x-auto rounded bg-muted p-1.5 text-[10px] leading-tight">
<code>{comment.codeContent}</code>
</pre>
)}
{/* Comment text */}
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground">
{comment.annotation}
</p>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@kandev/ui/button';
import { Textarea } from '@kandev/ui/textarea';
import { IconSend, IconX } from '@tabler/icons-react';
interface CommentFormProps {
/** Initial content for editing */
initialContent?: string;
/** Callback when comment is submitted */
onSubmit: (content: string) => void;
/** Callback when form is cancelled */
onCancel: () => void;
/** Whether the form is in editing mode */
isEditing?: boolean;
/** Auto focus the textarea */
autoFocus?: boolean;
}
export function CommentForm({
initialContent = '',
onSubmit,
onCancel,
isEditing = false,
autoFocus = true,
}: CommentFormProps) {
const [content, setContent] = useState(initialContent);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
const handleSubmit = () => {
const trimmed = content.trim();
if (trimmed) {
onSubmit(trimmed);
setContent('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
return (
<div className="flex flex-col gap-2 rounded-md border border-border bg-card p-2 shadow-md">
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment..."
className="min-h-[60px] resize-none text-xs"
rows={2}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to submit
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={onCancel}
className="h-6 cursor-pointer px-2 text-xs"
>
<IconX className="mr-1 h-3 w-3" />
Cancel
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!content.trim()}
className="h-6 cursor-pointer px-2 text-xs"
>
<IconSend className="mr-1 h-3 w-3" />
{isEditing ? 'Update' : 'Add'}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,4 @@
'use client';
// Re-export from the unified diff-viewer component
export { DiffViewInline } from './diff-viewer';
+507
View File
@@ -0,0 +1,507 @@
'use client';
import { useState, useCallback, useMemo, memo, useSyncExternalStore, type ReactNode } from 'react';
import { useTheme } from 'next-themes';
import { FileDiff } from '@pierre/diffs/react';
import { parsePatchFiles, parseDiffFromFile } from '@pierre/diffs';
import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeaderMetadataProps, AnnotationSide, SelectedLineRange } from '@pierre/diffs';
import { cn } from '@kandev/ui/lib/utils';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
import { useDiffComments } from './use-diff-comments';
import { CommentForm } from './comment-form';
import { CommentDisplay } from './comment-display';
// Local storage key for global diff view mode
const DIFF_VIEW_MODE_KEY = 'diff-view-mode';
const DEFAULT_VIEW_MODE = 'unified' as const;
// Custom event for syncing view mode across components
const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change';
type ViewMode = 'split' | 'unified';
// Helper to get view mode from localStorage
function getStoredViewMode(): ViewMode {
if (typeof window === 'undefined') return DEFAULT_VIEW_MODE;
const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY);
return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE;
}
// Helper to set view mode in localStorage and dispatch event
function setStoredViewMode(mode: ViewMode): void {
localStorage.setItem(DIFF_VIEW_MODE_KEY, mode);
window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode }));
}
// Hook to subscribe to global view mode changes
function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] {
const subscribe = useCallback((callback: () => void) => {
window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback);
window.addEventListener('storage', callback);
return () => {
window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback);
window.removeEventListener('storage', callback);
};
}, []);
const getSnapshot = useCallback(() => getStoredViewMode(), []);
const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []);
const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return [viewMode, setStoredViewMode];
}
interface DiffViewerProps {
/** Diff data to display */
data: FileDiffData;
/** Enable line selection for comments */
enableComments?: boolean;
/** Session ID for comment storage */
sessionId?: string;
/** Callback when comment is added */
onCommentAdd?: (comment: DiffComment) => void;
/** Callback when comment is deleted */
onCommentDelete?: (commentId: string) => void;
/** External comments (controlled mode) */
comments?: DiffComment[];
/** Additional class name */
className?: string;
/** Whether to show in compact mode (smaller text, no header) */
compact?: boolean;
/** Whether to hide the file header and toolbar */
hideHeader?: boolean;
/** Callback to open file in editor */
onOpenFile?: (filePath: string) => void;
}
type AnnotationMetadata = {
type: 'comment' | 'new-comment-form';
comment?: DiffComment;
isEditing?: boolean;
};
// Custom comparison for memo - only re-render if actual content changes
function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): boolean {
// Compare data by content, not reference
if (prevProps.data.filePath !== nextProps.data.filePath) return false;
if (prevProps.data.diff !== nextProps.data.diff) return false;
if (prevProps.data.oldContent !== nextProps.data.oldContent) return false;
if (prevProps.data.newContent !== nextProps.data.newContent) return false;
// Compare other props
if (prevProps.enableComments !== nextProps.enableComments) return false;
if (prevProps.sessionId !== nextProps.sessionId) return false;
if (prevProps.compact !== nextProps.compact) return false;
if (prevProps.hideHeader !== nextProps.hideHeader) return false;
if (prevProps.className !== nextProps.className) return false;
if (prevProps.onOpenFile !== nextProps.onOpenFile) return false;
// Comments array - compare by length and IDs
const prevComments = prevProps.comments;
const nextComments = nextProps.comments;
if (prevComments !== nextComments) {
if (!prevComments || !nextComments) return false;
if (prevComments.length !== nextComments.length) return false;
for (let i = 0; i < prevComments.length; i++) {
if (prevComments[i].id !== nextComments[i].id) return false;
if (prevComments[i].annotation !== nextComments[i].annotation) return false;
}
}
return true;
}
export const DiffViewer = memo(function DiffViewer({
data,
enableComments = false,
sessionId,
onCommentAdd,
onCommentDelete,
comments: externalComments,
className,
compact = false,
hideHeader = false,
onOpenFile,
}: DiffViewerProps) {
const { resolvedTheme } = useTheme();
const [selectedLines, setSelectedLines] = useState<SelectedLineRange | null>(null);
const [showCommentForm, setShowCommentForm] = useState(false);
// Global view mode (synced via localStorage)
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
// Local state for word wrap (per-diff)
const [wordWrap, setWordWrap] = useState(false);
// Use internal comment management if sessionId provided
const {
comments: internalComments,
addComment,
removeComment,
updateComment,
editingCommentId,
setEditingComment,
} = useDiffComments({
sessionId: sessionId || '',
filePath: data.filePath,
diff: data.diff,
newContent: data.newContent,
oldContent: data.oldContent,
});
const comments = externalComments || internalComments;
// Create annotations from comments + new comment form
const annotations = useMemo<DiffLineAnnotation<AnnotationMetadata>[]>(() => {
const result: DiffLineAnnotation<AnnotationMetadata>[] = comments.map((comment) => ({
side: comment.side,
lineNumber: comment.endLine,
metadata: {
type: 'comment' as const,
comment,
isEditing: editingCommentId === comment.id,
},
}));
// Add new comment form annotation at the selected line
if (showCommentForm && selectedLines) {
result.push({
side: (selectedLines.side || 'additions') as AnnotationSide,
lineNumber: Math.max(selectedLines.start, selectedLines.end),
metadata: {
type: 'new-comment-form' as const,
},
});
}
return result;
}, [comments, editingCommentId, showCommentForm, selectedLines]);
// Stable handler for line selection - only fires when selection is complete
const handleLineSelectionEnd = useCallback(
(range: SelectedLineRange | null) => {
setSelectedLines(range);
if (range && enableComments) {
setShowCommentForm(true);
}
},
[enableComments]
);
// Handler for comment submission
const handleCommentSubmit = useCallback(
(content: string) => {
if (!selectedLines) return;
if (onCommentAdd && externalComments !== undefined) {
const comment: DiffComment = {
id: `${data.filePath}-${Date.now()}`,
sessionId: sessionId || '',
filePath: data.filePath,
startLine: Math.min(selectedLines.start, selectedLines.end),
endLine: Math.max(selectedLines.start, selectedLines.end),
side: (selectedLines.side || 'additions') as DiffComment['side'],
codeContent: '',
annotation: content,
createdAt: new Date().toISOString(),
status: 'pending',
};
onCommentAdd(comment);
} else if (sessionId) {
addComment(selectedLines, content);
}
setShowCommentForm(false);
setSelectedLines(null);
},
[selectedLines, sessionId, data.filePath, addComment, onCommentAdd, externalComments]
);
// Handler for comment deletion
const handleCommentDelete = useCallback(
(commentId: string) => {
if (onCommentDelete && externalComments !== undefined) {
onCommentDelete(commentId);
} else {
removeComment(commentId);
}
},
[removeComment, onCommentDelete, externalComments]
);
// Handle comment update
const handleCommentUpdate = useCallback(
(commentId: string, content: string) => {
updateComment(commentId, { annotation: content });
setEditingComment(null);
},
[updateComment, setEditingComment]
);
// Render annotation callback
const renderAnnotation = useCallback(
(annotation: DiffLineAnnotation<AnnotationMetadata>): ReactNode => {
const { type, comment, isEditing } = annotation.metadata;
if (type === 'new-comment-form') {
return (
<div className="my-1 px-2">
<CommentForm
onSubmit={handleCommentSubmit}
onCancel={() => {
setShowCommentForm(false);
setSelectedLines(null);
}}
/>
</div>
);
}
if (isEditing && comment) {
return (
<div className="my-1 px-2">
<CommentForm
initialContent={comment.annotation}
onSubmit={(content) => handleCommentUpdate(comment.id, content)}
onCancel={() => setEditingComment(null)}
isEditing
/>
</div>
);
}
if (comment) {
return (
<div className="my-1 px-2">
<CommentDisplay
comment={comment}
onDelete={() => handleCommentDelete(comment.id)}
onEdit={() => setEditingComment(comment.id)}
showCode={false}
/>
</div>
);
}
return null;
},
[setEditingComment, handleCommentDelete, handleCommentUpdate, handleCommentSubmit]
);
// Render header metadata toolbar for each diff file
const renderHeaderMetadata = useCallback(
(props: RenderHeaderMetadataProps): ReactNode => {
const filePath = props.fileDiff?.name || data.filePath;
return (
<div className="flex items-center gap-1">
{/* Copy diff button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => navigator.clipboard.writeText(data.diff || '')}
>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy diff</TooltipContent>
</Tooltip>
{/* Revert button (non-functional placeholder) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => {
// TODO: Implement revert functionality
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Revert changes</TooltipContent>
</Tooltip>
{/* Word wrap toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100',
wordWrap && 'opacity-100 bg-muted'
)}
onClick={() => setWordWrap(!wordWrap)}
>
<IconTextWrap className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
{/* Split/Unified toggle (global) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => setGlobalViewMode(globalViewMode === 'split' ? 'unified' : 'split')}
>
{globalViewMode === 'split' ? (
<IconLayoutRows className="h-3.5 w-3.5" />
) : (
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'}
</TooltipContent>
</Tooltip>
{/* Open in editor button */}
{onOpenFile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onOpenFile(filePath)}
>
<IconExternalLink className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
)}
</div>
);
},
[data.filePath, data.diff, wordWrap, globalViewMode, setGlobalViewMode, onOpenFile]
);
// Render hover utility (+) icon for adding comments
const renderHoverUtility = useCallback(
(): ReactNode => {
// Only show when comments are enabled
if (!enableComments) return null;
return (
<div
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground"
title="Add comment"
>
<IconPlus className="h-3 w-3" />
</div>
);
},
[enableComments]
);
// Determine if header/toolbar should be shown
const showHeader = !hideHeader && !compact;
// Stable options - only include values that truly need to change the diff render
const options = useMemo<FileDiffOptions<AnnotationMetadata>>(
() => ({
diffStyle: globalViewMode,
themeType: resolvedTheme === 'dark' ? 'dark' : 'light',
theme: {
dark: 'github-dark-high-contrast',
light: 'github-light',
},
enableLineSelection: enableComments,
enableHoverUtility: enableComments,
diffIndicators: 'none',
onLineSelectionEnd: handleLineSelectionEnd,
disableFileHeader: !showHeader,
lineDiffType: 'word',
overflow: wordWrap ? 'wrap' : 'scroll',
}),
[globalViewMode, resolvedTheme, enableComments, showHeader, handleLineSelectionEnd, wordWrap]
);
// Compute FileDiffMetadata from either diff string or content
const fileDiffMetadata = useMemo<FileDiffMetadata | null>(() => {
if (data.diff) {
// Has diff string - parse it
const parsed = parsePatchFiles(data.diff);
return parsed[0]?.files[0] ?? null;
} else if (data.oldContent || data.newContent) {
// Has content - generate diff using library
return parseDiffFromFile(
{ name: data.filePath, contents: data.oldContent },
{ name: data.filePath, contents: data.newContent }
);
}
return null;
}, [data.diff, data.oldContent, data.newContent, data.filePath]);
// Only pass selectedLines when we have a completed selection (form shown)
// During drag, let FileDiff manage its own internal state
const controlledSelection = showCommentForm ? selectedLines : null;
if (!fileDiffMetadata) {
return (
<div className={cn(
'rounded-md border border-border/50 bg-muted/20 p-4 text-muted-foreground',
compact ? 'text-xs' : 'text-sm',
className
)}>
No diff available
</div>
);
}
return (
<div className={cn('diff-viewer relative', className)}>
<FileDiff
fileDiff={fileDiffMetadata}
options={options}
selectedLines={controlledSelection}
lineAnnotations={annotations}
renderAnnotation={renderAnnotation}
renderHeaderMetadata={showHeader ? renderHeaderMetadata : undefined}
renderHoverUtility={renderHoverUtility}
className={cn(
'rounded-md border border-border/50',
compact && 'text-xs'
)}
/>
</div>
);
}, arePropsEqual);
/**
* Compact inline diff viewer for chat messages.
* This is a convenience wrapper around DiffViewer with compact defaults.
* @deprecated Use DiffViewer with compact={true} instead
*/
export function DiffViewInline({
data,
className,
}: {
data: FileDiffData;
className?: string;
}) {
return (
<DiffViewer
data={data}
compact
hideHeader
className={className}
/>
);
}
@@ -0,0 +1,78 @@
'use client';
import { memo, useMemo } from 'react';
import { DiffViewer } from './diff-viewer';
import { transformGitDiff } from '@/lib/diff';
import type { DiffComment } from '@/lib/diff/types';
interface FileDiffViewerProps {
/** File path */
filePath: string;
/** Raw diff content */
diff: string;
/** File status (M, A, D, etc.) */
status?: string;
/** Enable line selection for comments */
enableComments?: boolean;
/** Session ID for comment storage */
sessionId?: string;
/** Callback when comment is added */
onCommentAdd?: (comment: DiffComment) => void;
/** Callback when comment is deleted */
onCommentDelete?: (commentId: string) => void;
/** External comments (controlled mode) */
comments?: DiffComment[];
/** Additional class name */
className?: string;
/** Whether to show in compact mode */
compact?: boolean;
/** Whether to hide the file header */
hideHeader?: boolean;
/** Callback to open file in editor */
onOpenFile?: (filePath: string) => void;
}
/**
* Wrapper around DiffViewer that handles data transformation with memoization.
*
* Use this component when you have raw diff data (filePath, diff, status).
* The transformation to FileDiffData is memoized internally, preventing
* unnecessary re-renders when parent components update.
*
* This is the recommended component to use for rendering git diffs.
*/
export const FileDiffViewer = memo(function FileDiffViewer({
filePath,
diff,
status = 'M',
enableComments,
sessionId,
onCommentAdd,
onCommentDelete,
comments,
className,
compact,
hideHeader,
onOpenFile,
}: FileDiffViewerProps) {
// Memoize the transformation - only recalculates when raw data changes
const data = useMemo(
() => transformGitDiff(filePath, diff, status),
[filePath, diff, status]
);
return (
<DiffViewer
data={data}
enableComments={enableComments}
sessionId={sessionId}
onCommentAdd={onCommentAdd}
onCommentDelete={onCommentDelete}
comments={comments}
className={className}
compact={compact}
hideHeader={hideHeader}
onOpenFile={onOpenFile}
/>
);
});
+9
View File
@@ -0,0 +1,9 @@
// Main components
export { DiffViewer } from './diff-viewer';
export { FileDiffViewer } from './file-diff-viewer';
export { DiffViewInline } from './diff-view-inline';
export { CommentForm } from './comment-form';
export { CommentDisplay } from './comment-display';
// Hooks
export { useDiffComments } from './use-diff-comments';
@@ -0,0 +1,141 @@
'use client';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import type { SelectedLineRange } from '@pierre/diffs';
import {
useDiffCommentsStore,
useFileComments,
} from '@/lib/state/slices/diff-comments';
import {
commentsToAnnotations,
extractCodeFromDiff,
extractCodeFromContent,
} from '@/lib/diff';
import type { DiffComment, AnnotationSide, CommentAnnotation } from '@/lib/diff/types';
interface UseDiffCommentsOptions {
sessionId: string;
filePath: string;
/** Diff string for extracting code from line selection */
diff?: string;
/** New content for extracting code from line selection */
newContent?: string;
/** Old content for extracting code from line selection */
oldContent?: string;
}
interface UseDiffCommentsReturn {
/** Comments for this file */
comments: DiffComment[];
/** Annotations formatted for @pierre/diffs */
annotations: CommentAnnotation[];
/** Add a new comment */
addComment: (
range: SelectedLineRange,
annotation: string
) => void;
/** Remove a comment */
removeComment: (commentId: string) => void;
/** Update a comment */
updateComment: (commentId: string, updates: Partial<DiffComment>) => void;
/** Currently editing comment ID */
editingCommentId: string | null;
/** Set the editing comment ID */
setEditingComment: (commentId: string | null) => void;
}
/**
* Hook to manage comments for a specific file's diff
*/
export function useDiffComments({
sessionId,
filePath,
diff,
newContent,
oldContent,
}: UseDiffCommentsOptions): UseDiffCommentsReturn {
const comments = useFileComments(sessionId, filePath);
const editingCommentId = useDiffCommentsStore(
(state) => state.editingCommentId
);
const storeAddComment = useDiffCommentsStore((state) => state.addComment);
const storeRemoveComment = useDiffCommentsStore((state) => state.removeComment);
const storeUpdateComment = useDiffCommentsStore((state) => state.updateComment);
const storeSetEditingComment = useDiffCommentsStore((state) => state.setEditingComment);
const annotations = useMemo(
() => commentsToAnnotations(comments),
[comments]
);
const addComment = useCallback(
(range: SelectedLineRange, annotation: string) => {
const side = (range.side || 'additions') as AnnotationSide;
const startLine = Math.min(range.start, range.end);
const endLine = Math.max(range.start, range.end);
// Extract the code content from the selected lines
let codeContent = '';
if (diff) {
codeContent = extractCodeFromDiff(diff, startLine, endLine, side);
} else {
const content = side === 'additions' ? newContent : oldContent;
if (content) {
codeContent = extractCodeFromContent(content, startLine, endLine);
}
}
const comment: DiffComment = {
id: `${filePath}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
filePath,
startLine,
endLine,
side,
codeContent,
annotation,
createdAt: new Date().toISOString(),
status: 'pending',
};
storeAddComment(comment);
toast.success('Comment added', {
description: 'Your comment will be sent with your next message.',
duration: 2000,
});
},
[sessionId, filePath, diff, newContent, oldContent, storeAddComment]
);
const removeComment = useCallback(
(commentId: string) => {
storeRemoveComment(sessionId, filePath, commentId);
},
[sessionId, filePath, storeRemoveComment]
);
const updateComment = useCallback(
(commentId: string, updates: Partial<DiffComment>) => {
storeUpdateComment(commentId, updates);
},
[storeUpdateComment]
);
const setEditingComment = useCallback(
(commentId: string | null) => {
storeSetEditingComment(commentId);
},
[storeSetEditingComment]
);
return {
comments,
annotations,
addComment,
removeComment,
updateComment,
editingCommentId,
setEditingComment,
};
}
@@ -1,97 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { SplitSide } from '@git-diff-view/react';
import { findRowsInRangeWithLineNumbers, groupByConsecutiveRowIndices } from './dom-utils';
import type { DiffComment } from './types';
interface CommentRangeIndicatorProps {
comment: DiffComment;
wrapperRef: React.RefObject<HTMLDivElement | null>;
viewMode: 'split' | 'unified';
}
interface IndicatorRect {
top: number;
height: number;
left: number | string;
width: string;
}
export function CommentRangeIndicator({
comment,
wrapperRef,
viewMode,
}: CommentRangeIndicatorProps) {
const [indicators, setIndicators] = useState<IndicatorRect[]>([]);
useEffect(() => {
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const rowsWithNumbers = findRowsInRangeWithLineNumbers(wrapper, viewMode, comment.side, comment.startLine, comment.endLine);
if (rowsWithNumbers.length === 0) {
setIndicators([]);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
const left = viewMode === 'split' && comment.side === SplitSide.old ? 0 : viewMode === 'split' ? '50%' : 0;
const width = viewMode === 'split' ? '50%' : '100%';
// Group rows by consecutive row indices (split view) or all together (unified view)
const groups = viewMode === 'split'
? groupByConsecutiveRowIndices(rowsWithNumbers)
: [rowsWithNumbers.map(r => r.row)];
const newIndicators: IndicatorRect[] = groups.map((group) => {
const firstRect = group[0].getBoundingClientRect();
const lastRect = group[group.length - 1].getBoundingClientRect();
return {
top: firstRect.top - wrapperRect.top + wrapper.scrollTop,
height: lastRect.bottom - firstRect.top,
left,
width,
};
});
setIndicators(newIndicators);
};
updatePosition();
const wrapper = wrapperRef.current;
wrapper?.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition);
return () => {
wrapper?.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
};
}, [comment, wrapperRef, viewMode]);
if (indicators.length === 0) return null;
return (
<>
{indicators.map((indicator, index) => (
<div
key={index}
style={{
display: 'block',
position: 'absolute',
top: indicator.top,
height: indicator.height,
left: indicator.left,
width: indicator.width,
backgroundColor: 'oklch(0.75 0.12 80 / 15%)',
borderRight: '3px solid oklch(0.7 0.15 80)',
pointerEvents: 'none',
zIndex: 5,
}}
/>
))}
</>
);
}
@@ -1,42 +0,0 @@
'use client';
import { SplitSide } from '@git-diff-view/react';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import { IconTrash } from '@tabler/icons-react';
import type { DiffComment } from './types';
interface CommentRendererProps {
comment: DiffComment;
onDelete: (id: string) => void;
}
export function CommentRenderer({ comment, onDelete }: CommentRendererProps) {
const isSingleLine = comment.startLine === comment.endLine;
return (
<div className="p-3 bg-amber-50 dark:bg-amber-950/30 border-t border-amber-200 dark:border-amber-800">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground mb-1">
{isSingleLine
? `Line ${comment.startLine}`
: `Lines ${comment.startLine}-${comment.endLine}`}
<Badge variant="outline" className="ml-2 text-xs">
{comment.side === SplitSide.old ? 'old' : 'new'}
</Badge>
</div>
<div className="text-sm whitespace-pre-wrap">{comment.content}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(comment.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive shrink-0"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
@@ -1,192 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { SplitSide, type DiffFile } from '@git-diff-view/react';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import { Textarea } from '@kandev/ui/textarea';
import type { DiffComment } from './types';
interface InlineCommentWidgetProps {
lineNumber: number;
side: SplitSide;
startLine: number;
diffFile: DiffFile;
wrapperRef: React.RefObject<HTMLDivElement | null>;
viewMode: 'split' | 'unified';
filePath: string;
onSave: (comment: DiffComment) => void;
onCancel: () => void;
onStartLineChange: (startLine: number) => void;
}
export function InlineCommentWidget({
lineNumber,
side,
startLine,
diffFile,
wrapperRef,
viewMode,
filePath,
onSave,
onCancel,
onStartLineChange,
}: InlineCommentWidgetProps) {
const [content, setContent] = useState('');
const coverRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const endLine = lineNumber;
const rangeStart = Math.min(startLine, endLine);
const rangeEnd = Math.max(startLine, endLine);
const isSingleLine = rangeStart === rangeEnd;
// Auto-focus textarea
useEffect(() => {
setTimeout(() => textareaRef.current?.focus(), 50);
}, []);
// Create visual overlay for selected lines (multi-line selection effect)
useEffect(() => {
if (!wrapperRef.current || isSingleLine) return;
let coverItem = coverRef.current;
if (!coverItem) {
coverItem = document.createElement('div');
coverRef.current = coverItem;
coverItem.className = 'diff-multiline-selection-overlay';
}
const updateCover = () => {
const id = diffFile.getId();
const wrapper = document.querySelector(`#diff-root${id}`);
if (!wrapper || !coverItem) return;
const items: HTMLElement[] = [];
for (let i = rangeStart; i <= rangeEnd; i++) {
const lineIndex =
viewMode === 'split'
? diffFile.getSplitLineIndexByLineNumber(i, side)
: diffFile.getUnifiedLineIndexByLineNumber(i, side);
if (lineIndex === undefined) continue;
const lineDom = wrapper.querySelector(`[data-line="${lineIndex + 1}"]`) as HTMLElement;
if (lineDom) {
items.push(lineDom);
}
}
if (items.length === 0) {
coverItem.style.display = 'none';
return;
}
const firstItem = items[0];
const lastItem = items[items.length - 1];
const wrapperEl = wrapperRef.current;
if (!wrapperEl) return;
const wrapperRect = wrapperEl.getBoundingClientRect();
const firstRect = firstItem.getBoundingClientRect();
const lastRect = lastItem.getBoundingClientRect();
coverItem.style.display = 'block';
coverItem.style.top = `${firstRect.top - wrapperRect.top + wrapperEl.scrollTop}px`;
coverItem.style.height = `${lastRect.bottom - firstRect.top}px`;
if (viewMode === 'split') {
coverItem.style.left = side === SplitSide.old ? '0' : '50%';
coverItem.style.width = '50%';
} else {
coverItem.style.left = '0';
coverItem.style.width = '100%';
}
if (!wrapperEl.contains(coverItem)) {
wrapperEl.appendChild(coverItem);
}
};
updateCover();
const unsubscribe = diffFile.subscribe(() => setTimeout(updateCover, 0));
return () => {
coverItem?.remove();
coverRef.current = null;
unsubscribe();
};
}, [rangeStart, rangeEnd, side, diffFile, wrapperRef, viewMode, isSingleLine]);
const handleSubmit = () => {
if (content.trim()) {
const comment: DiffComment = {
id: `${filePath}-${Date.now()}`,
startLine: rangeStart,
endLine: rangeEnd,
side,
content: content.trim(),
createdAt: new Date().toISOString(),
};
onSave(comment);
}
};
return (
<div className="p-3 bg-card border-t border-border">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Comment on</span>
{isSingleLine ? (
<span className="font-medium">line {rangeStart}</span>
) : (
<>
<span className="text-muted-foreground">lines</span>
<input
type="number"
min={1}
max={endLine}
value={startLine}
onChange={(e) =>
onStartLineChange(Math.max(1, Math.min(endLine, Number(e.target.value))))
}
className="w-16 px-2 py-0.5 text-center border border-border rounded text-sm bg-background"
/>
<span className="text-muted-foreground">to</span>
<span className="font-medium">{endLine}</span>
</>
)}
<Badge variant="outline" className="text-xs">
{side === SplitSide.old ? 'old' : 'new'}
</Badge>
</div>
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a comment..."
className="min-h-[80px] text-sm resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
if (e.key === 'Escape') {
onCancel();
}
}}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!content.trim()}>
Add Comment
</Button>
</div>
</div>
</div>
);
}
@@ -1,309 +0,0 @@
/**
* DOM utilities for @git-diff-view/react library integration.
*
* This module uses the library's stable data attributes for DOM queries:
* - data-line: line number on <tr> elements
* - data-side: "old" | "new" on rows (split view only)
* - data-state: "diff" | "plain" | "hunk" | "widget" | "extend"
*
* These data attributes are more stable than CSS class names across library versions.
*
* Fallback to class-based selectors is provided for unified view where
* data-side is not present.
*/
import { SplitSide } from '@git-diff-view/react';
// Stable data attribute selectors
export const SELECTORS = {
// Row with line number
ROW_WITH_LINE: 'tr[data-line]',
// Split view specific
ROW_OLD_SIDE: 'tr[data-side="old"]',
ROW_NEW_SIDE: 'tr[data-side="new"]',
// Fallback class selectors for unified view
CELL_LINE_NUM: 'td.diff-line-num',
CELL_OLD_NUM: 'td.diff-line-old-num',
CELL_NEW_NUM: 'td.diff-line-new-num',
CELL_OLD_CONTENT: 'td.diff-line-old-content',
CELL_NEW_CONTENT: 'td.diff-line-new-content',
} as const;
// Span indices in unified view line number cell
const UNIFIED_SPAN_OLD_INDEX = 0;
const UNIFIED_SPAN_NEW_INDEX = 2;
export interface LineInfo {
lineNumber: number;
side: SplitSide;
}
/**
* Parse line number from data attribute or text content
*/
function parseLineNumber(value: string | null): number | null {
if (!value) return null;
const match = value.trim().match(/^[+-]?(\d+)$/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Get line info from a row using data attributes (preferred) or class-based fallback
*/
function getLineInfoFromRow(
row: Element,
clickedElement: HTMLElement,
viewMode: 'split' | 'unified'
): LineInfo | null {
if (viewMode === 'split') {
// Check if clicked on a placeholder cell (empty cell on one side) - return null
const isOldPlaceholder = clickedElement.closest('td.diff-line-old-placeholder');
const isNewPlaceholder = clickedElement.closest('td.diff-line-new-placeholder');
if (isOldPlaceholder || isNewPlaceholder) {
return null;
}
// Split view: determine which side was clicked and check if it has a line number
const oldNumCell = row.querySelector(SELECTORS.CELL_OLD_NUM);
const newNumCell = row.querySelector(SELECTORS.CELL_NEW_NUM);
const oldContent = row.querySelector(SELECTORS.CELL_OLD_CONTENT);
const newContent = row.querySelector(SELECTORS.CELL_NEW_CONTENT);
// Check if clicked on old side
if (oldNumCell && (oldNumCell.contains(clickedElement) || oldContent?.contains(clickedElement))) {
const num = parseLineNumber(oldNumCell.textContent);
// Only return if there's actually a line number (not an empty cell)
if (num !== null) return { lineNumber: num, side: SplitSide.old };
// Empty cell on old side - return null to skip
return null;
}
// Check if clicked on new side
if (newNumCell && (newNumCell.contains(clickedElement) || newContent?.contains(clickedElement))) {
const num = parseLineNumber(newNumCell.textContent);
// Only return if there's actually a line number (not an empty cell)
if (num !== null) return { lineNumber: num, side: SplitSide.new };
// Empty cell on new side - return null to skip
return null;
}
// Fallback: try data attributes
const dataLine = row.getAttribute('data-line');
const dataSide = row.getAttribute('data-side');
const lineNumber = parseLineNumber(dataLine);
if (lineNumber !== null && dataSide) {
return {
lineNumber,
side: dataSide === 'old' ? SplitSide.old : SplitSide.new,
};
}
} else {
// Unified view: no data-side, need to parse line number cell
const lineNumCell = row.querySelector(SELECTORS.CELL_LINE_NUM);
if (!lineNumCell) return null;
const spans = lineNumCell.querySelectorAll('span');
// Unified view span layout: [old number] [separator] [new number]
let oldNum: number | null = null;
let newNum: number | null = null;
if (spans[UNIFIED_SPAN_OLD_INDEX]) {
oldNum = parseLineNumber(spans[UNIFIED_SPAN_OLD_INDEX].textContent);
}
if (spans[UNIFIED_SPAN_NEW_INDEX]) {
newNum = parseLineNumber(spans[UNIFIED_SPAN_NEW_INDEX].textContent);
}
// Prefer new side if available (additions show on new side)
if (newNum !== null) {
return { lineNumber: newNum, side: SplitSide.new };
}
if (oldNum !== null) {
return { lineNumber: oldNum, side: SplitSide.old };
}
// Final fallback: parse all text
const text = lineNumCell.textContent?.trim() || '';
const numbers = text.match(/\d+/g);
if (numbers && numbers.length > 0) {
const num = parseInt(numbers[numbers.length - 1], 10);
return {
lineNumber: num,
side: numbers.length > 1 ? SplitSide.new : SplitSide.old,
};
}
}
return null;
}
/**
* Extract line number and side from a DOM element within the diff viewer
*/
export function getLineInfoFromElement(
element: HTMLElement,
wrapper: HTMLElement | null,
viewMode: 'split' | 'unified'
): LineInfo | null {
let current: HTMLElement | null = element;
while (current && current !== wrapper) {
const row = current.closest('tr');
if (row) {
const info = getLineInfoFromRow(row, element, viewMode);
if (info) return info;
}
current = current.parentElement;
}
return null;
}
export interface RowWithLineNumber {
row: HTMLElement;
lineNumber: number;
rowIndex: number; // Index in DOM for detecting visual gaps
}
/**
* Find all rows in a line number range for overlay positioning
*/
export function findRowsInRange(
wrapper: HTMLElement,
viewMode: 'split' | 'unified',
side: SplitSide,
startLine: number,
endLine: number
): HTMLElement[] {
return findRowsInRangeWithLineNumbers(wrapper, viewMode, side, startLine, endLine).map(r => r.row);
}
/**
* Find all rows in a line number range, returning both row and line number
* This allows grouping by consecutive line numbers for split view
*/
export function findRowsInRangeWithLineNumbers(
wrapper: HTMLElement,
viewMode: 'split' | 'unified',
side: SplitSide,
startLine: number,
endLine: number
): RowWithLineNumber[] {
const items: RowWithLineNumber[] = [];
if (viewMode === 'split') {
// Get all rows to track row indices
const allRows = Array.from(wrapper.querySelectorAll('tr'));
const rowIndexMap = new Map<HTMLElement, number>();
allRows.forEach((row, idx) => rowIndexMap.set(row as HTMLElement, idx));
// Split view: use class-based selectors to get line numbers from cells
const sideClass = side === SplitSide.old ? SELECTORS.CELL_OLD_NUM : SELECTORS.CELL_NEW_NUM;
const lineNumCells = wrapper.querySelectorAll(sideClass);
lineNumCells.forEach((cell) => {
const num = parseLineNumber(cell.textContent);
if (num !== null && num >= startLine && num <= endLine) {
const row = cell.closest('tr') as HTMLElement;
if (row) {
const rowIndex = rowIndexMap.get(row) ?? -1;
items.push({ row, lineNumber: num, rowIndex });
}
}
});
// Sort by row index to ensure DOM order
items.sort((a, b) => a.rowIndex - b.rowIndex);
} else {
// Unified view: iterate through all line rows
const allRows = Array.from(wrapper.querySelectorAll('tr'));
const rowIndexMap = new Map<Element, number>();
allRows.forEach((row, idx) => rowIndexMap.set(row, idx));
const rows = wrapper.querySelectorAll(SELECTORS.ROW_WITH_LINE);
rows.forEach((row) => {
const lineNumCell = row.querySelector(SELECTORS.CELL_LINE_NUM);
if (!lineNumCell) return;
const spans = lineNumCell.querySelectorAll('span');
let targetNum: number | null = null;
// Check the appropriate span based on side - NO fallback to avoid matching wrong side
const spanIndex = side === SplitSide.old ? UNIFIED_SPAN_OLD_INDEX : UNIFIED_SPAN_NEW_INDEX;
if (spans[spanIndex]) {
targetNum = parseLineNumber(spans[spanIndex].textContent);
}
// Only use fallback if there are no spans (old library version)
if (targetNum === null && spans.length === 0) {
const text = lineNumCell.textContent?.trim() || '';
const numbers = text.match(/\d+/g);
if (numbers && numbers.length > 0) {
const idx = side === SplitSide.old ? 0 : numbers.length - 1;
targetNum = parseInt(numbers[idx], 10);
}
}
if (targetNum !== null && targetNum >= startLine && targetNum <= endLine) {
const rowIndex = rowIndexMap.get(row) ?? -1;
items.push({ row: row as HTMLElement, lineNumber: targetNum, rowIndex });
}
});
// Sort by row index
items.sort((a, b) => a.rowIndex - b.rowIndex);
}
// Remove duplicates based on row
const seen = new Set<HTMLElement>();
return items.filter(item => {
if (seen.has(item.row)) return false;
seen.add(item.row);
return true;
});
}
/**
* Find a specific row by line number for widget positioning
*/
export function findRowByLineNumber(
wrapper: HTMLElement,
viewMode: 'split' | 'unified',
side: SplitSide,
lineNumber: number
): HTMLElement | null {
const rows = findRowsInRange(wrapper, viewMode, side, lineNumber, lineNumber);
return rows[0] || null;
}
/**
* Group rows into contiguous ranges based on consecutive ROW INDICES.
* This detects visual gaps caused by rows with empty cells on the selected side.
* Used for split view to show separate overlays for non-adjacent rows.
*/
export function groupByConsecutiveRowIndices(items: RowWithLineNumber[]): HTMLElement[][] {
if (items.length === 0) return [];
const groups: HTMLElement[][] = [];
let currentGroup: HTMLElement[] = [items[0].row];
let lastRowIndex = items[0].rowIndex;
for (let i = 1; i < items.length; i++) {
const { row, rowIndex } = items[i];
if (rowIndex === lastRowIndex + 1) {
currentGroup.push(row);
} else {
groups.push(currentGroup);
currentGroup = [row];
}
lastRowIndex = rowIndex;
}
groups.push(currentGroup);
return groups;
}
@@ -1,33 +0,0 @@
/* Custom styles for @git-diff-view/react demo */
/* CSS Custom Properties */
:root {
--diff-selection-bg: oklch(0.85 0.08 260 / 30%);
--diff-selection-border: oklch(0.6 0.15 260);
}
.dark {
--diff-selection-bg: oklch(0.35 0.08 260 / 40%);
--diff-selection-border: oklch(0.65 0.15 260);
}
/* Multi-line selection overlay */
.diff-multiline-selection-overlay {
position: absolute;
background-color: var(--diff-selection-bg);
border: 2px solid var(--diff-selection-border);
border-radius: 2px;
pointer-events: none;
z-index: 10;
}
/* Ensure proper reset for color in widget/extend wrappers */
.diff-line-extend-wrapper,
.diff-line-widget-wrapper {
color: inherit;
}
.diff-line-extend-wrapper *,
.diff-line-widget-wrapper * {
color: inherit;
}
@@ -1,311 +0,0 @@
'use client';
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { DiffView, DiffModeEnum, SplitSide } from '@git-diff-view/react';
import { generateDiffFile } from '@git-diff-view/file';
import { useTheme } from 'next-themes';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { useDiffComments } from './use-diff-comments';
import { InlineCommentWidget } from './comment-widget';
import { CommentRenderer } from './comment-renderer';
import { DragSelectionOverlay } from './selection-overlay';
import { CommentRangeIndicator } from './comment-indicator';
import { PositionedCommentWidget } from './multi-line-comment';
import { getLineInfoFromElement } from './dom-utils';
import type { GitDiffViewerProps, ExtendLineData, DragSelectionState } from './types';
import '@git-diff-view/react/styles/diff-view.css';
import './git-diff-viewer.css';
export function GitDiffViewer({
oldContent,
newContent,
filePath,
language = 'typescript',
defaultViewMode = 'split',
showToolbar = true,
enableComments = true,
comments: externalComments,
onCommentAdd,
onCommentDelete,
className,
}: GitDiffViewerProps) {
const { resolvedTheme } = useTheme();
const [viewMode, setViewMode] = useState<'split' | 'unified'>(defaultViewMode);
const [widgetStartLines, setWidgetStartLines] = useState<Record<string, number>>({});
const widgetStartLinesRef = useRef(widgetStartLines);
useEffect(() => {
widgetStartLinesRef.current = widgetStartLines;
}, [widgetStartLines]);
// Drag selection state for multi-line comments
const [dragSelection, setDragSelection] = useState<DragSelectionState | null>(null);
const [showDragWidget, setShowDragWidget] = useState(false);
const justCompletedMultiLineRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
// Comment management
const { comments, addComment, deleteComment } = useDiffComments({
filePath,
externalComments,
onCommentAdd,
onCommentDelete,
});
const diffModeEnum = viewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
// Generate diff file
const diffFile = useMemo(() => {
try {
const file = generateDiffFile(filePath, oldContent, filePath, newContent, language, language);
file.initRaw();
return file;
} catch (e) {
console.error('Failed to generate diff:', e);
return null;
}
}, [filePath, oldContent, newContent, language]);
// Build extendData for displaying existing comments
const extendData = useMemo(() => {
const oldFileData: Record<string, { data: ExtendLineData }> = {};
const newFileData: Record<string, { data: ExtendLineData }> = {};
comments.forEach((comment) => {
const lineKey = String(comment.endLine);
const entry: ExtendLineData = { type: 'comment', comment };
if (comment.side === SplitSide.old) {
oldFileData[lineKey] = { data: entry };
} else {
newFileData[lineKey] = { data: entry };
}
});
return { oldFile: oldFileData, newFile: newFileData };
}, [comments]);
// Mouse handlers for drag selection
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!enableComments || e.button !== 0) return;
const target = e.target as HTMLElement;
const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
if (lineInfo) {
setDragSelection({
startLine: lineInfo.lineNumber,
endLine: lineInfo.lineNumber,
side: lineInfo.side,
isActive: true,
});
setShowDragWidget(false);
justCompletedMultiLineRef.current = false;
}
},
[enableComments, viewMode]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!dragSelection?.isActive) return;
const target = e.target as HTMLElement;
const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
if (lineInfo && lineInfo.side === dragSelection.side && lineInfo.lineNumber !== dragSelection.endLine) {
setDragSelection((prev) => (prev ? { ...prev, endLine: lineInfo.lineNumber } : null));
}
},
[dragSelection, viewMode]
);
const handleMouseUp = useCallback(() => {
if (!dragSelection?.isActive) return;
const { startLine, endLine, side } = dragSelection;
const rangeStart = Math.min(startLine, endLine);
const rangeEnd = Math.max(startLine, endLine);
if (rangeStart !== rangeEnd) {
setDragSelection({ startLine: rangeStart, endLine: rangeEnd, side, isActive: false });
setShowDragWidget(true);
justCompletedMultiLineRef.current = true;
} else {
setDragSelection(null);
}
}, [dragSelection]);
const handleMouseLeave = useCallback(() => {
if (dragSelection?.isActive) {
setDragSelection(null);
}
}, [dragSelection]);
// Prevent library's single-line widget after multi-line selection
const handleClick = useCallback((e: React.MouseEvent) => {
if (justCompletedMultiLineRef.current) {
e.stopPropagation();
e.preventDefault();
justCompletedMultiLineRef.current = false;
}
}, []);
// Handle widget line rendering for single-line comments
const renderWidgetLine = useCallback(
(props: { side: SplitSide; lineNumber: number; onClose: () => void }) => {
if (!diffFile || !enableComments) return null;
const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`;
const startLine = widgetStartLinesRef.current[widgetKey] ?? props.lineNumber;
return (
<InlineCommentWidget
key={widgetKey}
lineNumber={props.lineNumber}
side={props.side}
startLine={startLine}
diffFile={diffFile}
wrapperRef={wrapperRef}
viewMode={viewMode}
filePath={filePath}
onSave={(comment) => {
addComment(comment.startLine, comment.endLine, comment.side, comment.content);
setWidgetStartLines((prev) => {
const next = { ...prev };
delete next[widgetKey];
return next;
});
props.onClose();
}}
onCancel={() => {
setWidgetStartLines((prev) => {
const next = { ...prev };
delete next[widgetKey];
return next;
});
props.onClose();
}}
onStartLineChange={(newStartLine) => {
setWidgetStartLines((prev) => ({ ...prev, [widgetKey]: newStartLine }));
}}
/>
);
},
[filePath, diffFile, viewMode, enableComments, addComment]
);
// Render existing comments inline
const renderExtendLine = useCallback(
(lineData: { data: ExtendLineData }) => {
if (!lineData.data) return null;
return <CommentRenderer comment={lineData.data.comment} onDelete={(id) => deleteComment(id)} />;
},
[deleteComment]
);
if (!diffFile) {
return (
<div className={cn('flex items-center justify-center p-6', className)}>
<div className="text-muted-foreground">Failed to generate diff</div>
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{showToolbar && (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{filePath}
</Badge>
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Button
type="button"
variant="ghost"
size="sm"
className={cn('h-7 px-2 text-xs rounded-none cursor-pointer', viewMode === 'unified' && 'bg-muted')}
onClick={() => setViewMode('unified')}
>
<IconLayoutRows className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn('h-7 px-2 text-xs rounded-none cursor-pointer', viewMode === 'split' && 'bg-muted')}
onClick={() => setViewMode('split')}
>
<IconLayoutColumns className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* Outer container for positioning widget outside overflow */}
<div className="relative">
<div
ref={wrapperRef}
className="relative border border-border rounded-lg overflow-x-auto select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClickCapture={handleClick}
>
{/* Comment range indicators */}
{enableComments &&
comments.map((comment) => (
<CommentRangeIndicator key={comment.id} comment={comment} wrapperRef={wrapperRef} viewMode={viewMode} />
))}
{/* Drag selection overlay */}
{enableComments && dragSelection && diffFile && (
<DragSelectionOverlay selection={dragSelection} wrapperRef={wrapperRef} viewMode={viewMode} />
)}
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={diffTheme}
diffViewHighlight
diffViewMode={diffModeEnum}
diffViewFontSize={12}
diffViewAddWidget={enableComments}
onAddWidgetClick={() => {}}
renderWidgetLine={renderWidgetLine}
extendData={extendData}
renderExtendLine={renderExtendLine}
/>
</div>
{/* Multi-line comment widget - outside overflow container */}
{enableComments && showDragWidget && dragSelection && !dragSelection.isActive && diffFile && (
<PositionedCommentWidget
selection={dragSelection}
diffFile={diffFile}
wrapperRef={wrapperRef}
viewMode={viewMode}
onSave={(content) => {
addComment(dragSelection.startLine, dragSelection.endLine, dragSelection.side, content);
setDragSelection(null);
setShowDragWidget(false);
}}
onCancel={() => {
setDragSelection(null);
setShowDragWidget(false);
}}
/>
)}
</div>
</div>
);
}
@@ -1,10 +0,0 @@
export { GitDiffViewer } from './git-diff-viewer';
export { useDiffComments } from './use-diff-comments';
export type {
DiffComment,
GitDiffViewerProps,
DragSelectionState,
ExtendLineData,
CommentWidgetProps,
SplitSide,
} from './types';
@@ -1,126 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { SplitSide, type DiffFile } from '@git-diff-view/react';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import { Textarea } from '@kandev/ui/textarea';
import { findRowByLineNumber } from './dom-utils';
import type { DragSelectionState } from './types';
interface MultiLineCommentInputProps {
startLine: number;
endLine: number;
side: SplitSide;
onSave: (content: string) => void;
onCancel: () => void;
}
export function MultiLineCommentInput({
startLine,
endLine,
side,
onSave,
onCancel,
}: MultiLineCommentInputProps) {
const [content, setContent] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Comment on lines</span>
<span className="font-medium">{startLine}</span>
<span className="text-muted-foreground">to</span>
<span className="font-medium">{endLine}</span>
<Badge variant="outline" className="text-xs">
{side === SplitSide.old ? 'old' : 'new'}
</Badge>
</div>
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a comment..."
className="min-h-[80px] text-sm resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if (content.trim()) onSave(content.trim());
}
if (e.key === 'Escape') {
onCancel();
}
}}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={() => content.trim() && onSave(content.trim())} disabled={!content.trim()}>
Add Comment
</Button>
</div>
</div>
);
}
interface PositionedCommentWidgetProps {
selection: DragSelectionState;
diffFile: DiffFile;
wrapperRef: React.RefObject<HTMLDivElement | null>;
viewMode: 'split' | 'unified';
onSave: (content: string) => void;
onCancel: () => void;
}
export function PositionedCommentWidget({
selection,
diffFile,
wrapperRef,
viewMode,
onSave,
onCancel,
}: PositionedCommentWidgetProps) {
const [position, setPosition] = useState<{ top: number; left: number | string; width: string } | null>(null);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const endRow = findRowByLineNumber(wrapper, viewMode, selection.side, selection.endLine);
if (endRow) {
const wrapperRect = wrapper.getBoundingClientRect();
const rowRect = endRow.getBoundingClientRect();
setPosition({
top: rowRect.bottom - wrapperRect.top + wrapper.scrollTop,
left: viewMode === 'split' && selection.side === SplitSide.old ? 0 : viewMode === 'split' ? '50%' : 0,
width: viewMode === 'split' ? '50%' : '100%',
});
}
}, [selection, diffFile, wrapperRef, viewMode]);
if (!position) return null;
return (
<div
className="absolute z-30 bg-card border border-border rounded shadow-lg p-3"
style={{ top: position.top, left: position.left, width: position.width }}
>
<MultiLineCommentInput
startLine={selection.startLine}
endLine={selection.endLine}
side={selection.side}
onSave={onSave}
onCancel={onCancel}
/>
</div>
);
}
@@ -1,98 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { SplitSide } from '@git-diff-view/react';
import { findRowsInRangeWithLineNumbers, groupByConsecutiveRowIndices } from './dom-utils';
import type { DragSelectionState } from './types';
interface DragSelectionOverlayProps {
selection: DragSelectionState;
wrapperRef: React.RefObject<HTMLDivElement | null>;
viewMode: 'split' | 'unified';
}
interface OverlayRect {
top: number;
height: number;
left: number | string;
width: string;
}
export function DragSelectionOverlay({
selection,
wrapperRef,
viewMode,
}: DragSelectionOverlayProps) {
const [overlays, setOverlays] = useState<OverlayRect[]>([]);
useEffect(() => {
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const rangeStart = Math.min(selection.startLine, selection.endLine);
const rangeEnd = Math.max(selection.startLine, selection.endLine);
const rowsWithNumbers = findRowsInRangeWithLineNumbers(wrapper, viewMode, selection.side, rangeStart, rangeEnd);
if (rowsWithNumbers.length === 0) {
setOverlays([]);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
const left = viewMode === 'split' && selection.side === SplitSide.old ? 0 : viewMode === 'split' ? '50%' : 0;
const width = viewMode === 'split' ? '50%' : '100%';
// Group rows by consecutive row indices (split view) or all together (unified view)
const groups = viewMode === 'split'
? groupByConsecutiveRowIndices(rowsWithNumbers)
: [rowsWithNumbers.map(r => r.row)];
const newOverlays: OverlayRect[] = groups.map((group) => {
const firstRect = group[0].getBoundingClientRect();
const lastRect = group[group.length - 1].getBoundingClientRect();
return {
top: firstRect.top - wrapperRect.top + wrapper.scrollTop,
height: lastRect.bottom - firstRect.top,
left,
width,
};
});
setOverlays(newOverlays);
};
updatePosition();
const wrapper = wrapperRef.current;
wrapper?.addEventListener('scroll', updatePosition);
return () => wrapper?.removeEventListener('scroll', updatePosition);
}, [selection, wrapperRef, viewMode]);
if (overlays.length === 0) return null;
return (
<>
{overlays.map((overlay, index) => (
<div
key={index}
className="diff-multiline-selection-overlay"
style={{
display: 'block',
position: 'absolute',
top: overlay.top,
height: overlay.height,
left: overlay.left,
width: overlay.width,
backgroundColor: 'oklch(0.35 0.08 260 / 40%)',
border: '2px solid oklch(0.65 0.15 260)',
borderRadius: 2,
pointerEvents: 'none',
zIndex: 10,
}}
/>
))}
</>
);
}
@@ -1,77 +0,0 @@
import type { SplitSide, DiffFile } from '@git-diff-view/react';
/**
* Comment attached to a line range in a diff
*/
export interface DiffComment {
id: string;
startLine: number;
endLine: number;
side: SplitSide;
content: string;
createdAt: string;
}
/**
* Extended line data for the library's extendData prop
*/
export interface ExtendLineData {
type: 'comment';
comment: DiffComment;
}
/**
* Props for the main GitDiffViewer component
*/
export interface GitDiffViewerProps {
/** Old file content */
oldContent: string;
/** New file content */
newContent: string;
/** File path (used for comment storage key) */
filePath: string;
/** File language for syntax highlighting (e.g., 'tsx', 'typescript') */
language?: string;
/** Initial view mode */
defaultViewMode?: 'split' | 'unified';
/** Whether to show the toolbar */
showToolbar?: boolean;
/** Whether to enable comments */
enableComments?: boolean;
/** External comments (if managing state externally) */
comments?: DiffComment[];
/** Callback when a comment is added */
onCommentAdd?: (comment: DiffComment) => void;
/** Callback when a comment is deleted */
onCommentDelete?: (commentId: string) => void;
/** Additional class name for the container */
className?: string;
}
/**
* Selection state during drag
*/
export interface DragSelectionState {
startLine: number;
endLine: number;
side: SplitSide;
isActive: boolean;
}
/**
* Props for comment widget components
*/
export interface CommentWidgetProps {
lineNumber: number;
side: SplitSide;
startLine: number;
diffFile: DiffFile;
wrapperRef: React.RefObject<HTMLDivElement | null>;
viewMode: 'split' | 'unified';
filePath: string;
onSave: (comment: DiffComment) => void;
onCancel: () => void;
onStartLineChange: (startLine: number) => void;
}
export { SplitSide };
@@ -1,102 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import type { DiffComment } from './types';
import { SplitSide } from '@git-diff-view/react';
const STORAGE_KEY_PREFIX = 'git-diff-comments';
function getStorageKey(filePath: string): string {
return `${STORAGE_KEY_PREFIX}:${filePath}`;
}
function loadComments(filePath: string): DiffComment[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(getStorageKey(filePath));
if (!raw) return [];
return JSON.parse(raw) as DiffComment[];
} catch {
return [];
}
}
function saveComments(filePath: string, comments: DiffComment[]): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(getStorageKey(filePath), JSON.stringify(comments));
} catch {
// Ignore write failures
}
}
export interface UseDiffCommentsOptions {
/** File path for localStorage key */
filePath: string;
/** External comments (controlled mode) */
externalComments?: DiffComment[];
/** Callback when comment is added (controlled mode) */
onCommentAdd?: (comment: DiffComment) => void;
/** Callback when comment is deleted (controlled mode) */
onCommentDelete?: (commentId: string) => void;
}
export interface UseDiffCommentsReturn {
comments: DiffComment[];
addComment: (startLine: number, endLine: number, side: SplitSide, content: string) => void;
deleteComment: (commentId: string) => void;
}
export function useDiffComments({
filePath,
externalComments,
onCommentAdd,
onCommentDelete,
}: UseDiffCommentsOptions): UseDiffCommentsReturn {
const isControlled = externalComments !== undefined;
const [internalComments, setInternalComments] = useState<DiffComment[]>(() => {
if (isControlled) return [];
return loadComments(filePath);
});
// Persist internal comments to localStorage
useEffect(() => {
if (!isControlled) {
saveComments(filePath, internalComments);
}
}, [filePath, internalComments, isControlled]);
const comments = isControlled ? externalComments : internalComments;
const addComment = useCallback(
(startLine: number, endLine: number, side: SplitSide, content: string) => {
const newComment: DiffComment = {
id: `${filePath}-${Date.now()}`,
startLine,
endLine,
side,
content,
createdAt: new Date().toISOString(),
};
if (isControlled && onCommentAdd) {
onCommentAdd(newComment);
} else {
setInternalComments((prev) => [...prev, newComment]);
}
},
[filePath, isControlled, onCommentAdd]
);
const deleteComment = useCallback(
(commentId: string) => {
if (isControlled && onCommentDelete) {
onCommentDelete(commentId);
} else {
setInternalComments((prev) => prev.filter((c) => c.id !== commentId));
}
},
[isControlled, onCommentDelete]
);
return { comments, addComment, deleteComment };
}
@@ -26,9 +26,11 @@ import { RichTextInput, type RichTextInputHandle } from './rich-text-input';
import { MentionMenu } from './mention-menu';
import { SlashCommandMenu } from './slash-command-menu';
import { ClarificationInputOverlay } from './clarification-input-overlay';
import { CommentBlocksContainer } from './comment-block';
import { useInlineMention } from '@/hooks/use-inline-mention';
import { useInlineSlash } from '@/hooks/use-inline-slash';
import type { Message } from '@/lib/types/http';
import type { DiffComment } from '@/lib/diff/types';
// Memoized toolbar to prevent re-renders on input value changes
type ChatInputToolbarProps = {
@@ -163,7 +165,7 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
});
type ChatInputContainerProps = {
onSubmit: (message: string) => void;
onSubmit: (message: string, reviewComments?: DiffComment[]) => void;
sessionId: string | null;
taskId: string | null;
taskTitle?: string;
@@ -179,6 +181,14 @@ type ChatInputContainerProps = {
onClarificationResolved?: () => void;
showRequestChangesTooltip?: boolean;
onRequestChangesTooltipDismiss?: () => void;
/** Pending review comments grouped by file */
pendingCommentsByFile?: Record<string, DiffComment[]>;
/** Callback to remove comments for a file */
onRemoveCommentFile?: (filePath: string) => void;
/** Callback to remove a specific comment */
onRemoveComment?: (sessionId: string, filePath: string, commentId: string) => void;
/** Callback when a comment is clicked (for jump-to-line) */
onCommentClick?: (comment: DiffComment) => void;
};
export function ChatInputContainer({
@@ -197,6 +207,11 @@ export function ChatInputContainer({
pendingClarification,
onClarificationResolved,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
pendingCommentsByFile,
onRemoveCommentFile,
onRemoveComment,
onCommentClick,
}: ChatInputContainerProps) {
// Keep input state local to avoid re-rendering parent on every keystroke
const [value, setValue] = useState('');
@@ -238,13 +253,23 @@ export function ChatInputContainer({
menuStateRef.current = { mentionOpen: mention.isOpen, slashOpen: slash.isOpen };
}, [mention.isOpen, slash.isOpen]);
// Ref for pending comments
const pendingCommentsRef = useRef(pendingCommentsByFile);
useEffect(() => {
pendingCommentsRef.current = pendingCommentsByFile;
}, [pendingCommentsByFile]);
// Combined change handler
const handleChange = useCallback(
(newValue: string) => {
mention.handleChange(newValue);
slash.handleChange(newValue);
// Dismiss the request changes tooltip when user starts typing
if (showRequestChangesTooltip && onRequestChangesTooltipDismiss) {
onRequestChangesTooltipDismiss();
}
},
[mention, slash]
[mention, slash, showRequestChangesTooltip, onRequestChangesTooltipDismiss]
);
// Combined keydown handler
@@ -267,14 +292,28 @@ export function ChatInputContainer({
const handleSubmit = useCallback(() => {
// Don't submit if a menu is open
if (menuStateRef.current.mentionOpen || menuStateRef.current.slashOpen) return;
const trimmed = valueRef.current.trim();
if (!trimmed) return;
onSubmit(trimmed);
const comments = pendingCommentsRef.current;
// Collect all pending comments
const allComments: DiffComment[] = [];
if (comments) {
for (const filePath of Object.keys(comments)) {
allComments.push(...comments[filePath]);
}
}
// Allow submission if there's text OR comments
if (!trimmed && allComments.length === 0) return;
onSubmit(trimmed, allComments.length > 0 ? allComments : undefined);
setValue('');
}, [onSubmit]);
const isDisabled = isStarting || isSending;
const hasPendingClarification = pendingClarification && onClarificationResolved;
const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0;
return (
<div
@@ -282,7 +321,8 @@ export function ChatInputContainer({
'rounded-2xl border border-border bg-background shadow-md overflow-hidden',
planModeEnabled && 'border-primary border-dashed',
hasPendingClarification && 'border-blue-500/50',
showRequestChangesTooltip && 'animate-pulse border-orange-500'
showRequestChangesTooltip && 'animate-pulse border-orange-500',
hasPendingComments && 'border-amber-500/50'
)}
>
{/* Popup menus */}
@@ -307,6 +347,19 @@ export function ChatInputContainer({
setSelectedIndex={slash.setSelectedIndex}
/>
{/* Pending comment blocks */}
{hasPendingComments && sessionId && onRemoveCommentFile && onRemoveComment && (
<div className="px-3 pt-3">
<CommentBlocksContainer
commentsByFile={pendingCommentsByFile}
sessionId={sessionId}
onRemoveFile={onRemoveCommentFile}
onRemoveComment={onRemoveComment}
onCommentClick={onCommentClick}
/>
</div>
)}
{/* Clarification inline (replaces input area when pending) */}
{hasPendingClarification ? (
<div style={{ overflow: 'auto' }}>
@@ -0,0 +1,177 @@
'use client';
import { useState } from 'react';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@kandev/ui/collapsible';
import { IconChevronDown, IconChevronRight, IconX, IconMessage } from '@tabler/icons-react';
import { cn } from '@kandev/ui/lib/utils';
import type { DiffComment } from '@/lib/diff/types';
import { CommentDisplay } from '@/components/diff/comment-display';
interface CommentBlockProps {
/** File path for the comments */
filePath: string;
/** Comments for this file */
comments: DiffComment[];
/** Callback to remove all comments for this file */
onRemove: () => void;
/** Callback to remove a specific comment */
onRemoveComment: (commentId: string) => void;
/** Callback when a comment is clicked (for jump-to-line) */
onCommentClick?: (comment: DiffComment) => void;
/** Additional class name */
className?: string;
}
/**
* Rich block component for embedding review comments in chat input.
* Shows file name with comment count badge, expandable to see details.
*/
export function CommentBlock({
filePath,
comments,
onRemove,
onRemoveComment,
onCommentClick,
className,
}: CommentBlockProps) {
const [isOpen, setIsOpen] = useState(false);
// Get just the filename for display
const fileName = filePath.split('/').pop() || filePath;
if (comments.length === 0) {
return null;
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'rounded-lg border border-border bg-muted/30',
className
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-2 py-1.5">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 cursor-pointer p-0"
>
{isOpen ? (
<IconChevronDown className="h-3.5 w-3.5" />
) : (
<IconChevronRight className="h-3.5 w-3.5" />
)}
</Button>
</CollapsibleTrigger>
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
<span
className="min-w-0 flex-1 truncate text-xs font-medium"
title={filePath}
>
{fileName}
</span>
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{comments.length}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
>
<IconX className="h-3 w-3" />
</Button>
</div>
{/* Expanded content */}
<CollapsibleContent>
<div className="space-y-1.5 border-t border-border/50 px-2 py-2">
{comments.map((comment) => (
<div
key={comment.id}
onClick={() => onCommentClick?.(comment)}
className={cn(
'rounded transition-colors',
onCommentClick && 'cursor-pointer hover:bg-muted/50'
)}
>
<CommentDisplay
comment={comment}
compact
onDelete={() => onRemoveComment(comment.id)}
/>
</div>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
interface CommentBlocksContainerProps {
/** Comments grouped by file path */
commentsByFile: Record<string, DiffComment[]>;
/** Session ID for removing comments */
sessionId: string;
/** Callback to remove comments for a file */
onRemoveFile: (filePath: string) => void;
/** Callback to remove a specific comment */
onRemoveComment: (sessionId: string, filePath: string, commentId: string) => void;
/** Callback when a comment is clicked (for jump-to-line) */
onCommentClick?: (comment: DiffComment) => void;
/** Additional class name */
className?: string;
}
/**
* Container for multiple CommentBlock components
*/
export function CommentBlocksContainer({
commentsByFile,
sessionId,
onRemoveFile,
onRemoveComment,
onCommentClick,
className,
}: CommentBlocksContainerProps) {
const files = Object.keys(commentsByFile);
if (files.length === 0) {
return null;
}
return (
<div className={cn('space-y-2', className)}>
{files.map((filePath) => (
<CommentBlock
key={filePath}
filePath={filePath}
comments={commentsByFile[filePath]}
onRemove={() => onRemoveFile(filePath)}
onRemoveComment={(commentId) => onRemoveComment(sessionId, filePath, commentId)}
onCommentClick={onCommentClick}
/>
))}
</div>
);
}
@@ -1,27 +1,27 @@
'use client';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { useTheme } from 'next-themes';
import { IconCode } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { DiffPayload } from '@/components/task/chat/types';
import { DiffViewInline } from '@/components/diff';
import type { FileDiffData } from '@/lib/diff/types';
type DiffViewBlockProps = {
diff: DiffPayload;
/** Diff data in the new format */
data: FileDiffData;
/** Title to display */
title?: string;
/** Whether to show the title */
showTitle?: boolean;
/** Additional class name */
className?: string;
};
export function DiffViewBlock({
diff,
data,
title = 'Diff',
showTitle = true,
className
}: DiffViewBlockProps) {
const { resolvedTheme } = useTheme();
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
return (
<div className={cn("mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs", className)}>
{showTitle && (
@@ -30,15 +30,7 @@ export function DiffViewBlock({
<span>{title}</span>
</div>
)}
<DiffView
data={{
hunks: diff.hunks,
oldFile: diff.oldFile ?? { fileName: 'before', fileLang: 'plaintext' },
newFile: diff.newFile ?? { fileName: 'after', fileLang: 'plaintext' },
}}
diffViewMode={DiffModeEnum.Unified}
diffViewTheme={diffTheme}
/>
<DiffViewInline data={data} />
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
import { useState } from 'react';
import { Button } from '@kandev/ui/button';
import { Badge } from '@kandev/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@kandev/ui/collapsible';
import { IconChevronDown, IconChevronRight, IconMessage } from '@tabler/icons-react';
import { cn } from '@kandev/ui/lib/utils';
import type { DiffComment } from '@/lib/diff/types';
import { formatLineRange } from '@/lib/diff';
interface ReviewCommentsAttachmentProps {
/** Review comments from the message */
comments: DiffComment[];
/** Additional class name */
className?: string;
}
/**
* Renders review comments attached to a sent user message.
* Shows a compact summary that expands to show full comment details.
*/
export function ReviewCommentsAttachment({
comments,
className,
}: ReviewCommentsAttachmentProps) {
const [isOpen, setIsOpen] = useState(false);
if (!comments || comments.length === 0) {
return null;
}
// Group comments by file
const byFile: Record<string, DiffComment[]> = {};
for (const comment of comments) {
if (!byFile[comment.filePath]) {
byFile[comment.filePath] = [];
}
byFile[comment.filePath].push(comment);
}
const fileCount = Object.keys(byFile).length;
const totalComments = comments.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'mt-2 rounded-lg border border-border bg-muted/30',
className
)}
>
{/* Header */}
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-start gap-2 px-3 py-2"
>
{isOpen ? (
<IconChevronDown className="h-4 w-4 shrink-0" />
) : (
<IconChevronRight className="h-4 w-4 shrink-0" />
)}
<IconMessage className="h-4 w-4 shrink-0 text-blue-500" />
<span className="text-sm font-medium">
Review Comments
</span>
<Badge
variant="secondary"
className="ml-auto text-xs"
>
{totalComments} comment{totalComments !== 1 ? 's' : ''} on {fileCount} file{fileCount !== 1 ? 's' : ''}
</Badge>
</Button>
</CollapsibleTrigger>
{/* Expanded content */}
<CollapsibleContent>
<div className="border-t border-border/50 px-3 py-2">
{Object.entries(byFile).map(([filePath, fileComments]) => (
<div key={filePath} className="mb-3 last:mb-0">
{/* File header */}
<div className="mb-1.5 flex items-center gap-1.5 text-xs">
<span className="font-medium text-muted-foreground">
{filePath.split('/').pop()}
</span>
<span className="text-muted-foreground/60">
({fileComments.length})
</span>
</div>
{/* Comments */}
<div className="space-y-2">
{fileComments.map((comment) => (
<div
key={comment.id}
className="rounded-md border border-border/50 bg-card p-2"
>
{/* Line info */}
<div className="mb-1 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="font-medium">
{formatLineRange(comment.startLine, comment.endLine)}
</span>
<span>({comment.side === 'additions' ? 'new' : 'old'})</span>
</div>
{/* Code preview */}
{comment.codeContent && (
<pre className="mb-1.5 overflow-x-auto rounded bg-muted p-1.5 text-[10px] leading-tight">
<code>{comment.codeContent}</code>
</pre>
)}
{/* Comment text */}
<p className="whitespace-pre-wrap text-xs leading-relaxed">
{comment.annotation}
</p>
</div>
))}
</div>
</div>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
/**
* Format review comments as human-readable markdown for sending to agent.
* This format is both agent-friendly and displays nicely in the chat UI.
*/
export function formatReviewCommentsAsMarkdown(comments: DiffComment[]): string {
if (!comments || comments.length === 0) {
return '';
}
const lines: string[] = ['### Review Comments', ''];
// Group comments by file
const byFile = new Map<string, DiffComment[]>();
for (const comment of comments) {
const existing = byFile.get(comment.filePath) || [];
existing.push(comment);
byFile.set(comment.filePath, existing);
}
for (const [filePath, fileComments] of byFile) {
for (const comment of fileComments) {
const lineRange = comment.startLine === comment.endLine
? `${comment.startLine}`
: `${comment.startLine}-${comment.endLine}`;
lines.push(`**${filePath}:${lineRange}**`);
lines.push('```');
lines.push(comment.codeContent);
lines.push('```');
lines.push(`> ${comment.annotation}`);
lines.push('');
}
}
lines.push('---');
lines.push('');
return lines.join('\n');
}
@@ -3,27 +3,65 @@
import { IconBrain, IconCode, IconListCheck } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import type { DiffPayload, RichMetadata } from '@/components/task/chat/types';
import type { RichMetadata } from '@/components/task/chat/types';
import { DiffViewBlock } from '@/components/task/chat/messages/diff-view-block';
import { normalizeDiffString } from '@/lib/diff';
import type { FileDiffData } from '@/lib/diff/types';
function resolveDiffPayload(diff: unknown): DiffPayload | null {
/**
* Resolve old diff payload format to new FileDiffData format
*/
function resolveDiffPayload(diff: unknown): FileDiffData | null {
if (!diff) return null;
// Handle string diff
if (typeof diff === 'string') {
return null;
const normalized = normalizeDiffString(diff, 'file');
if (!normalized) return null;
return {
filePath: 'file',
oldContent: '',
newContent: '',
diff: normalized,
additions: 0,
deletions: 0,
};
}
// Handle array of hunks (legacy format)
if (Array.isArray(diff)) {
return { hunks: diff.map((hunk) => String(hunk)) };
const hunkStrings = diff.map((hunk) => String(hunk)).join('\n');
const normalized = normalizeDiffString(hunkStrings, 'file');
if (!normalized) return null;
return {
filePath: 'file',
oldContent: '',
newContent: '',
diff: normalized,
additions: 0,
deletions: 0,
};
}
// Handle object with hunks array (legacy format)
if (typeof diff === 'object' && diff !== null) {
const candidate = diff as Partial<DiffPayload>;
const candidate = diff as { hunks?: unknown[]; oldFile?: { fileName?: string }; newFile?: { fileName?: string } };
if (Array.isArray(candidate.hunks)) {
const hunkStrings = candidate.hunks.map((hunk) => String(hunk)).join('\n');
const filePath = candidate.newFile?.fileName || candidate.oldFile?.fileName || 'file';
const normalized = normalizeDiffString(hunkStrings, filePath);
if (!normalized) return null;
return {
hunks: candidate.hunks.map((hunk) => String(hunk)),
oldFile: candidate.oldFile,
newFile: candidate.newFile,
filePath,
oldContent: '',
newContent: '',
diff: normalized,
additions: 0,
deletions: 0,
};
}
}
return null;
}
@@ -35,7 +73,7 @@ export function RichBlocks({ comment }: { comment: Message }) {
const todoItems = todos
.map((item) => (typeof item === 'string' ? { text: item, done: false } : item))
.filter((item) => item.text);
const diffPayload = resolveDiffPayload(metadata.diff);
const diffData = resolveDiffPayload(metadata.diff);
const diffText = typeof metadata.diff === 'string' ? metadata.diff : null;
return (
@@ -72,10 +110,10 @@ export function RichBlocks({ comment }: { comment: Message }) {
</div>
</div>
)}
{diffPayload && (
<DiffViewBlock diff={diffPayload} />
{diffData && (
<DiffViewBlock data={diffData} />
)}
{!diffPayload && diffText && (
{!diffData && diffText && (
<div className="mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs">
<div className="flex items-center gap-2 text-muted-foreground mb-1 uppercase tracking-wide">
<IconCode className="h-3.5 w-3.5" />
@@ -8,14 +8,7 @@ import { FilePathButton } from './file-path-button';
import type { Message } from '@/lib/types/http';
import { DiffViewBlock } from './diff-view-block';
import { ExpandableRow } from './expandable-row';
type FileMutation = {
type?: string;
content?: string;
old_content?: string;
new_content?: string;
diff?: string;
};
import { transformFileMutation, type FileMutation } from '@/lib/diff';
type ModifyFilePayload = {
file_path?: string;
@@ -33,14 +26,6 @@ type ToolEditMetadata = {
normalized?: NormalizedPayload;
};
// Parse unified diff string into hunks array for DiffViewBlock
function parseDiffToHunks(diffString: string): string[] {
if (!diffString) return [];
// Split on hunk headers (@@ ... @@) but keep them
const hunks = diffString.split(/(?=^@@)/m).filter(h => h.trim());
return hunks;
}
type ToolEditMessageProps = {
comment: Message;
worktreePath?: string;
@@ -56,17 +41,15 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
const normalized = metadata?.normalized;
const filePath = normalized?.modify_file?.file_path;
const mutation = normalized?.modify_file?.mutations?.[0];
const diffString = mutation?.diff;
const writeContent = mutation?.content; // For Write tool (create operations)
const isWriteOperation = mutation?.type === 'create';
const diff = diffString ? {
hunks: parseDiffToHunks(diffString),
oldFile: { fileName: filePath },
newFile: { fileName: filePath },
} : undefined;
// Transform mutation to FileDiffData using the new adapter
const diffData = filePath && mutation
? transformFileMutation(filePath, mutation)
: null;
const hasExpandableContent = !!(diff || writeContent);
const hasExpandableContent = !!(diffData?.diff || writeContent);
const isSuccess = status === 'complete';
const Icon = isWriteOperation ? IconFilePlus : IconEdit;
@@ -132,8 +115,8 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
isExpanded={isExpanded}
onToggle={handleToggle}
>
{diff ? (
<DiffViewBlock diff={diff} showTitle={false} className="mt-0 border-0 px-0" />
{diffData?.diff ? (
<DiffViewBlock data={diffData} showTitle={false} className="mt-0 border-0 px-0" />
) : writeContent ? (
<div className="pl-4 border-l-2 border-border/30">
<pre className="text-xs bg-muted/30 rounded p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap">
@@ -308,6 +308,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
<TaskChangesPanel
selectedDiff={selectedDiff}
onClearSelected={() => setSelectedDiff(null)}
onOpenFile={handleOpenFileFromChat}
/>
</TabsContent>
@@ -323,6 +324,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
onOpenFile={handleOpenFileFromChat}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={() => setShowRequestChangesTooltip(false)}
onSelectDiff={(path) => {
setSelectedDiff({ path });
setLeftTab('changes');
}}
/>
)
) : (
+51 -171
View File
@@ -1,14 +1,7 @@
'use client';
import { memo, useMemo, useState } from 'react';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import {
IconArrowBackUp,
IconCopy,
IconLayoutColumns,
IconLayoutRows,
} from '@tabler/icons-react';
import { useTheme } from 'next-themes';
import { IconArrowBackUp } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Button } from '@kandev/ui/button';
import { SessionPanelContent } from '@kandev/ui/pannel-session';
@@ -23,71 +16,52 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@kandev/ui/alert-dialog';
import { cn } from '@/lib/utils';
import { useToast } from '@/components/toast-provider';
import { getLanguageFromPath } from '@/lib/languages';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { useAppStore } from '@/components/state-provider';
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
import { useGitOperations } from '@/hooks/use-git-operations';
import { FileDiffViewer } from '@/components/diff';
import type { FileInfo } from '@/lib/state/slices';
import type { SelectedDiff } from './task-layout';
type TaskChangesPanelProps = {
selectedDiff: SelectedDiff | null;
onClearSelected: () => void;
/** Callback to open file in editor */
onOpenFile?: (filePath: string) => void;
};
const DEFAULT_DIFF_MODE: 'unified' | 'split' = 'unified';
/**
* Normalize diff content for the @git-diff-view/react library.
* Normalize diff content by handling edge cases from the backend.
*
* Handles two edge cases from the backend:
* Handles:
* 1. Multiple diffs concatenated with "--- Staged changes ---" separator
* (we take the staged diff as it's what matters for the commit)
* 2. Diffs missing the "diff --git" header line (for untracked files)
*
* The library expects each hunk to be a complete diff string with headers.
* 2. Diffs missing headers
*/
function parseDiffIntoHunks(diffContent: string, filePath?: string): string[] {
if (!diffContent || typeof diffContent !== 'string') return [];
function normalizeDiffContent(diffContent: string): string {
if (!diffContent || typeof diffContent !== 'string') return '';
let trimmed = diffContent.trim();
if (!trimmed) return [];
if (!trimmed) return '';
// Handle concatenated diffs with "--- Staged changes ---" separator
// Take the staged diff (after the separator) as it's the authoritative one
const stagedSeparator = '--- Staged changes ---';
if (trimmed.includes(stagedSeparator)) {
const parts = trimmed.split(stagedSeparator);
// Use the staged diff (second part) if available
trimmed = (parts[1] || parts[0]).trim();
}
// If diff is missing the "diff --git" header, add it
// This happens for untracked files where backend generates incomplete diff
if (!trimmed.startsWith('diff --git') && trimmed.includes('@@')) {
const path = filePath || 'file';
const header = `diff --git a/${path} b/${path}\nnew file mode 100644\nindex 0000000..0000000\n`;
trimmed = header + trimmed;
}
// Return as single hunk - the library handles parsing internally
return [trimmed];
return trimmed;
}
const TaskChangesPanel = memo(function TaskChangesPanel({
selectedDiff,
onClearSelected,
onOpenFile,
}: TaskChangesPanelProps) {
const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() =>
getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE)
);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const { resolvedTheme } = useTheme();
const { toast } = useToast();
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
@@ -124,8 +98,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
}));
}, [cumulativeDiff]);
const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null;
// Use provided content if available, otherwise fall back to git status
const selectedDiffContent = selectedDiff?.content ?? selectedFile?.diff ?? '';
@@ -138,20 +110,16 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
const hasAnyChanges = hasUncommitted || hasCommitted;
const handleDiscardClick = () => {
console.log('Discard button clicked', { selectedDiffPath, canDiscard });
setShowDiscardDialog(true);
};
const handleDiscardConfirm = async () => {
if (!selectedDiffPath) {
console.error('No selectedDiffPath');
return;
}
console.log('Discarding changes for:', selectedDiffPath);
try {
const result = await discard([selectedDiffPath]);
console.log('Discard result:', result);
if (result.success) {
toast({
title: 'Changes discarded',
@@ -167,7 +135,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
});
}
} catch (error) {
console.error('Discard error:', error);
toast({
title: 'Failed to discard changes',
description: error instanceof Error ? error.message : 'An unknown error occurred',
@@ -180,91 +147,23 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
return (
<div className="flex flex-col gap-2 h-full">
{/* Header toolbar */}
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="rounded-full text-xs">
{selectedDiffPath ?? 'All files'}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{selectedDiffPath && (
{selectedDiffPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
className="h-7 px-2 text-xs cursor-pointer ml-auto"
onClick={onClearSelected}
>
Clear
</Button>
</TooltipTrigger>
<TooltipContent>Show all uncommitted changes</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
disabled={!isSingleDiffSelected}
onClick={async () => {
if (!isSingleDiffSelected) return;
await navigator.clipboard.writeText(selectedDiffContent);
}}
>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Copy diff</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
disabled={!canDiscard || isDiscarding}
onClick={handleDiscardClick}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'}
</TooltipContent>
</Tooltip>
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 px-2 text-xs rounded-none cursor-pointer',
diffViewMode === 'unified' && 'bg-muted'
)}
onClick={() => {
setDiffViewMode('unified');
setLocalStorage('task-diff-view-mode', 'unified');
}}
>
<IconLayoutRows className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Unified view</TooltipContent>
<TooltipContent>Show all changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -273,39 +172,34 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 px-2 text-xs rounded-none cursor-pointer',
diffViewMode === 'split' && 'bg-muted'
)}
onClick={() => {
setDiffViewMode('split');
setLocalStorage('task-diff-view-mode', 'split');
}}
className="h-7 px-2 text-xs cursor-pointer"
disabled={!canDiscard || isDiscarding}
onClick={handleDiscardClick}
>
<IconLayoutColumns className="h-3.5 w-3.5" />
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Split view</TooltipContent>
<TooltipContent>
{canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'}
</TooltipContent>
</Tooltip>
</div>
</div>
</>
)}
</div>
{/* Diff content */}
<SessionPanelContent>
{isSingleDiffSelected && selectedDiffContent ? (
// Render single file diff (from commit, snapshot, or uncommitted)
// Key includes content length to force re-render when diff updates
<div key={`selected-${selectedDiffPath}-${selectedDiffContent.length}`} className="space-y-4">
<DiffView
data={{
hunks: parseDiffIntoHunks(selectedDiffContent, selectedDiffPath!),
oldFile: { fileName: selectedDiffPath!, fileLang: getLanguageFromPath(selectedDiffPath!) },
newFile: { fileName: selectedDiffPath!, fileLang: getLanguageFromPath(selectedDiffPath!) },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
<FileDiffViewer
filePath={selectedDiffPath!}
diff={normalizeDiffContent(selectedDiffContent)}
status="M"
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
) : cumulativeLoading ? (
@@ -326,29 +220,17 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
</div>
{uncommittedFiles.map((file) => {
if (!file.diff) return null;
const fileLang = getLanguageFromPath(file.path);
// Include diff length in key to force re-render when content changes
const diffKey = `uncommitted-${file.path}-${file.diff.length}`;
const normalizedDiff = normalizeDiffContent(file.diff);
const diffKey = `uncommitted-${file.path}-${normalizedDiff.length}`;
return (
<div key={diffKey} className="space-y-2">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{file.path}
</Badge>
<span className="text-xs text-muted-foreground">
<span className="text-emerald-500">+{file.additions}</span>
{' / '}
<span className="text-rose-500">-{file.deletions}</span>
</span>
</div>
<DiffView
data={{
hunks: parseDiffIntoHunks(file.diff, file.path),
oldFile: { fileName: file.path, fileLang },
newFile: { fileName: file.path, fileLang },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
<FileDiffViewer
filePath={file.path}
diff={normalizedDiff}
status={file.status}
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
);
@@ -364,9 +246,8 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
</div>
{committedFiles.map((file) => {
if (!file.diff) return null;
const fileLang = getLanguageFromPath(file.path);
// Include diff length in key to force re-render when content changes
const diffKey = `committed-${file.path}-${file.diff.length}`;
const normalizedDiff = normalizeDiffContent(file.diff);
const diffKey = `committed-${file.path}-${normalizedDiff.length}`;
return (
<div key={diffKey} className="space-y-2">
<div className="flex items-center justify-between">
@@ -379,14 +260,13 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
<span className="text-rose-500">-{file.deletions}</span>
</span>
</div>
<DiffView
data={{
hunks: parseDiffIntoHunks(file.diff, file.path),
oldFile: { fileName: file.path, fileLang },
newFile: { fileName: file.path, fileLang },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
<FileDiffViewer
filePath={file.path}
diff={normalizedDiff}
status="M"
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
);
+57 -6
View File
@@ -11,6 +11,12 @@ import { useMessageHandler } from '@/hooks/use-message-handler';
import { TodoSummary } from '@/components/task/chat/todo-summary';
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
import { ChatInputContainer } from '@/components/task/chat/chat-input-container';
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
import {
useDiffCommentsStore,
usePendingCommentsByFile,
} from '@/lib/state/slices/diff-comments';
import type { DiffComment } from '@/lib/diff/types';
type TaskChatPanelProps = {
onSend?: (message: string) => void;
@@ -18,6 +24,8 @@ type TaskChatPanelProps = {
onOpenFile?: (path: string) => void;
showRequestChangesTooltip?: boolean;
onRequestChangesTooltipDismiss?: () => void;
/** Callback to select a file in the changes panel (for jump-to-line) */
onSelectDiff?: (path: string) => void;
};
export const TaskChatPanel = memo(function TaskChatPanel({
@@ -26,6 +34,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onOpenFile,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
onSelectDiff,
}: TaskChatPanelProps) {
const [planModeEnabled, setPlanModeEnabled] = useState(false);
const [isSending, setIsSending] = useState(false);
@@ -79,6 +88,31 @@ export const TaskChatPanel = memo(function TaskChatPanel({
planModeEnabled
);
// Diff comments management
const pendingCommentsByFile = usePendingCommentsByFile();
const markCommentsSent = useDiffCommentsStore((state) => state.markCommentsSent);
const removeComment = useDiffCommentsStore((state) => state.removeComment);
// Handle removing all comments for a file
const handleRemoveCommentFile = useCallback(
(filePath: string) => {
if (!resolvedSessionId) return;
const comments = pendingCommentsByFile[filePath] || [];
for (const comment of comments) {
removeComment(resolvedSessionId, filePath, comment.id);
}
},
[resolvedSessionId, pendingCommentsByFile, removeComment]
);
// Handle removing a single comment
const handleRemoveComment = useCallback(
(sessionId: string, filePath: string, commentId: string) => {
removeComment(sessionId, filePath, commentId);
},
[removeComment]
);
// Clear awaiting state when a new agent message arrives
useEffect(() => {
lastAgentMessageCountRef.current = agentMessageCount;
@@ -98,15 +132,28 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}
}, [resolvedSessionId]);
const handleSubmit = useCallback(async (message: string) => {
const handleSubmit = useCallback(async (message: string, reviewComments?: DiffComment[]) => {
if (isSending) return;
setIsSending(true);
try {
if (onSend) {
await onSend(message);
} else {
await handleSendMessage(message);
// Build final message with review comments
let finalMessage = message;
if (reviewComments && reviewComments.length > 0) {
const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments);
finalMessage = reviewMarkdown + (message ? message : '');
}
if (onSend) {
await onSend(finalMessage);
} else {
await handleSendMessage(finalMessage);
}
// Mark comments as sent and clear pending
if (reviewComments && reviewComments.length > 0) {
markCommentsSent(reviewComments.map((c) => c.id));
}
// Reset plan mode after sending - it's a per-message instruction
if (planModeEnabled) {
setPlanModeEnabled(false);
@@ -114,7 +161,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
} finally {
setIsSending(false);
}
}, [isSending, planModeEnabled, onSend, handleSendMessage]);
}, [isSending, planModeEnabled, onSend, handleSendMessage, markCommentsSent]);
return (
@@ -157,6 +204,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onClarificationResolved={handleClarificationResolved}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
pendingCommentsByFile={pendingCommentsByFile}
onRemoveCommentFile={handleRemoveCommentFile}
onRemoveComment={handleRemoveComment}
onCommentClick={onSelectDiff ? (comment) => onSelectDiff(comment.filePath) : undefined}
/>
</div>
</>
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import '@git-diff-view/react/styles/diff-view.css';
import { TaskTopBar } from '@/components/task/task-top-bar';
import { TaskLayout } from '@/components/task/task-layout';
import { DebugOverlay } from '@/components/debug-overlay';
+1
View File
@@ -37,6 +37,7 @@ export function DataTable<TData, TValue>({
isLoading = false,
onRowClick,
}: DataTableProps<TData, TValue>) {
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table's API is designed this way
const table = useReactTable({
data,
columns,
+176
View File
@@ -0,0 +1,176 @@
import type { FileDiffData, DiffComment, AnnotationSide, CommentAnnotation } from './types';
/**
* Format line range for display (e.g., "L10" or "L10-15")
*/
export function formatLineRange(startLine: number, endLine: number): string {
return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
}
/**
* Frontend types that mirror backend FileMutation
*/
export interface FileMutation {
type: 'create' | 'replace' | 'patch' | 'delete' | 'rename';
content?: string;
old_content?: string;
new_content?: string;
diff?: string;
new_path?: string;
start_line?: number;
end_line?: number;
}
export interface ModifyFilePayload {
file_path: string;
mutations: FileMutation[];
}
/**
* Normalize a diff string by ensuring it has proper headers.
* Required because backend diffs may not include full git headers.
*/
export function normalizeDiffString(diff: string, filePath: string): string {
if (!diff) return '';
const trimmed = diff.trim();
// Check if the diff already has headers
if (trimmed.startsWith('diff --git')) {
return trimmed;
}
// Check if it has file headers but not diff header
const hasFileHeaders = trimmed.includes('---') && trimmed.includes('+++');
if (hasFileHeaders) {
// Add just the diff header
return `diff --git a/${filePath} b/${filePath}\n${trimmed}`;
}
// Add minimal headers
const headers = [
`diff --git a/${filePath} b/${filePath}`,
`--- a/${filePath}`,
`+++ b/${filePath}`,
];
return headers.join('\n') + '\n' + trimmed;
}
/**
* Transform a backend FileMutation to FileDiffData for @pierre/diffs.
* The DiffViewer component handles diff generation from content using the library.
*/
export function transformFileMutation(
filePath: string,
mutation: FileMutation
): FileDiffData {
return {
filePath: mutation.new_path || filePath,
oldContent: mutation.old_content || '',
newContent: mutation.new_content || mutation.content || '',
diff: mutation.diff ? normalizeDiffString(mutation.diff, filePath) : undefined,
additions: 0,
deletions: 0,
};
}
/**
* Transform a git status diff string to FileDiffData.
* Language detection is handled automatically by the library.
*/
export function transformGitDiff(
filePath: string,
diff: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_status: 'A' | 'M' | 'D' | '??' | string
): FileDiffData {
return {
filePath,
oldContent: '',
newContent: '',
diff: normalizeDiffString(diff, filePath),
additions: 0,
deletions: 0,
};
}
/**
* Convert DiffComment[] to @pierre/diffs DiffLineAnnotation[]
*/
export function commentsToAnnotations(
comments: DiffComment[]
): CommentAnnotation[] {
return comments.map((comment) => ({
side: comment.side,
lineNumber: comment.endLine, // Anchor at the end of the range
metadata: {
comment,
isEditing: false,
},
}));
}
/**
* Extract code content from diff for a line range
*/
export function extractCodeFromDiff(
diff: string,
startLine: number,
endLine: number,
side: AnnotationSide
): string {
const lines = diff.split('\n');
const resultLines: string[] = [];
let currentNewLine = 0;
let currentOldLine = 0;
for (const line of lines) {
// Parse hunk header
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@/);
if (hunkMatch) {
currentOldLine = parseInt(hunkMatch[1], 10);
currentNewLine = parseInt(hunkMatch[2], 10);
continue;
}
// Skip file headers
if (line.startsWith('diff --git') || line.startsWith('---') || line.startsWith('+++')) {
continue;
}
if (line.startsWith('+') && !line.startsWith('+++')) {
if (side === 'additions' && currentNewLine >= startLine && currentNewLine <= endLine) {
resultLines.push(line.substring(1));
}
currentNewLine++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
if (side === 'deletions' && currentOldLine >= startLine && currentOldLine <= endLine) {
resultLines.push(line.substring(1));
}
currentOldLine++;
} else if (line.startsWith(' ') || (!line.startsWith('@') && line !== '')) {
const content = line.startsWith(' ') ? line.substring(1) : line;
const lineNum = side === 'additions' ? currentNewLine : currentOldLine;
if (lineNum >= startLine && lineNum <= endLine) {
resultLines.push(content);
}
currentOldLine++;
currentNewLine++;
}
}
return resultLines.join('\n');
}
/**
* Extract code content from full file content for a line range
*/
export function extractCodeFromContent(
content: string,
startLine: number,
endLine: number
): string {
const lines = content.split('\n');
return lines.slice(startLine - 1, endLine).join('\n');
}
+26
View File
@@ -0,0 +1,26 @@
// Types
export type {
AnnotationSide,
DiffComment,
FileDiffData,
CommentAnnotation,
DiffCommentsState,
DiffCommentsActions,
DiffCommentsSlice,
CommentBlockData,
DiffViewerProps,
DiffViewInlineProps,
} from './types';
// Adapter functions
export {
formatLineRange,
transformFileMutation,
transformGitDiff,
commentsToAnnotations,
normalizeDiffString,
extractCodeFromDiff,
extractCodeFromContent,
type FileMutation,
type ModifyFilePayload,
} from './adapter';
+124
View File
@@ -0,0 +1,124 @@
import type { DiffLineAnnotation } from '@pierre/diffs';
/**
* Annotation side - matches @pierre/diffs AnnotationSide
*/
export type AnnotationSide = 'additions' | 'deletions';
/**
* Comment attached to a line range in a diff
*/
export interface DiffComment {
id: string;
sessionId: string;
filePath: string;
startLine: number;
endLine: number;
side: AnnotationSide;
codeContent: string; // The actual code lines the user selected
annotation: string; // User's comment text
createdAt: string;
status: 'pending' | 'sent';
}
/**
* File diff data in a unified format for @pierre/diffs.
* Language is auto-detected by the library from file extension.
*/
export interface FileDiffData {
filePath: string;
oldContent: string;
newContent: string;
diff?: string; // Unified diff string (for patch mode)
additions: number;
deletions: number;
}
/**
* @pierre/diffs annotation type with our comment metadata
*/
export type CommentAnnotation = DiffLineAnnotation<{
comment: DiffComment;
isEditing: boolean;
}>;
/**
* Store state for diff comments
*/
export interface DiffCommentsState {
// Comments by session and file: sessionId -> filePath -> comments
bySession: Record<string, Record<string, DiffComment[]>>;
// Comments queued for next chat message (referenced by id)
pendingForChat: string[];
// Currently editing comment (null if none)
editingCommentId: string | null;
}
/**
* Actions for the diff comments store
*/
export interface DiffCommentsActions {
addComment: (comment: DiffComment) => void;
updateComment: (commentId: string, updates: Partial<DiffComment>) => void;
removeComment: (sessionId: string, filePath: string, commentId: string) => void;
addToPending: (commentId: string) => void;
removeFromPending: (commentId: string) => void;
clearPending: () => void;
setEditingComment: (commentId: string | null) => void;
markCommentsSent: (commentIds: string[]) => void;
getCommentsForFile: (sessionId: string, filePath: string) => DiffComment[];
getPendingComments: () => DiffComment[];
clearSessionComments: (sessionId: string) => void;
}
/**
* Combined state and actions
*/
export type DiffCommentsSlice = DiffCommentsState & DiffCommentsActions;
/**
* Rich text input block type for comment blocks
*/
export interface CommentBlockData {
type: 'comment-block';
filePath: string;
commentIds: string[];
}
/**
* Props for DiffViewer component
*/
export interface DiffViewerProps {
/** Diff data to display */
data: FileDiffData;
/** View mode: split or unified */
viewMode?: 'split' | 'unified';
/** Enable line selection for comments */
enableComments?: boolean;
/** Session ID for comment storage */
sessionId?: string;
/** Callback when comment is added */
onCommentAdd?: (comment: DiffComment) => void;
/** Callback when comment is deleted */
onCommentDelete?: (commentId: string) => void;
/** External comments (controlled mode) */
comments?: DiffComment[];
/** Additional class name */
className?: string;
/** Whether to show in compact mode (for chat) */
compact?: boolean;
}
/**
* Props for inline diff view in chat messages
*/
export interface DiffViewInlineProps {
/** Diff data to display */
data: FileDiffData;
/** Session ID for comment storage */
sessionId?: string;
/** Enable comments */
enableComments?: boolean;
/** Additional class name */
className?: string;
}
@@ -0,0 +1,252 @@
import { useMemo } from 'react';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type {
DiffCommentsState,
DiffCommentsSlice,
DiffComment,
} from '@/lib/diff/types';
const STORAGE_KEY = 'kandev-diff-comments';
// Stable empty references to avoid creating new objects on every render
const EMPTY_COMMENTS: DiffComment[] = [];
const EMPTY_COMMENTS_BY_FILE: Record<string, DiffComment[]> = {};
const defaultState: DiffCommentsState = {
bySession: {},
pendingForChat: [],
editingCommentId: null,
};
/**
* Standalone Zustand store for diff comments with localStorage persistence.
* Comments are organized by sessionId and filePath.
*/
export const useDiffCommentsStore = create<DiffCommentsSlice>()(
persist(
immer<DiffCommentsSlice>((set, get) => ({
...defaultState,
addComment: (comment: DiffComment) =>
set((state) => {
const { sessionId, filePath } = comment;
if (!state.bySession[sessionId]) {
state.bySession[sessionId] = {};
}
if (!state.bySession[sessionId][filePath]) {
state.bySession[sessionId][filePath] = [];
}
state.bySession[sessionId][filePath].push(comment);
// Auto-add to pending for chat
if (comment.status === 'pending') {
state.pendingForChat.push(comment.id);
}
}),
updateComment: (commentId: string, updates: Partial<DiffComment>) =>
set((state) => {
// Find and update the comment
for (const sessionId of Object.keys(state.bySession)) {
for (const filePath of Object.keys(state.bySession[sessionId])) {
const comments = state.bySession[sessionId][filePath];
const index = comments.findIndex((c) => c.id === commentId);
if (index !== -1) {
state.bySession[sessionId][filePath][index] = {
...comments[index],
...updates,
};
return;
}
}
}
}),
removeComment: (sessionId: string, filePath: string, commentId: string) =>
set((state) => {
if (!state.bySession[sessionId]?.[filePath]) return;
state.bySession[sessionId][filePath] = state.bySession[sessionId][
filePath
].filter((c) => c.id !== commentId);
// Remove from pending if present
state.pendingForChat = state.pendingForChat.filter(
(id) => id !== commentId
);
// Clear editing if this was the editing comment
if (state.editingCommentId === commentId) {
state.editingCommentId = null;
}
}),
addToPending: (commentId: string) =>
set((state) => {
if (!state.pendingForChat.includes(commentId)) {
state.pendingForChat.push(commentId);
}
}),
removeFromPending: (commentId: string) =>
set((state) => {
state.pendingForChat = state.pendingForChat.filter(
(id) => id !== commentId
);
}),
clearPending: () =>
set((state) => {
state.pendingForChat = [];
}),
setEditingComment: (commentId: string | null) =>
set((state) => {
state.editingCommentId = commentId;
}),
markCommentsSent: (commentIds: string[]) =>
set((state) => {
const idsToRemove = new Set(commentIds);
// Remove the sent comments entirely from storage
for (const sessionId of Object.keys(state.bySession)) {
for (const filePath of Object.keys(state.bySession[sessionId])) {
state.bySession[sessionId][filePath] = state.bySession[sessionId][
filePath
].filter((comment) => !idsToRemove.has(comment.id));
// Clean up empty file entries
if (state.bySession[sessionId][filePath].length === 0) {
delete state.bySession[sessionId][filePath];
}
}
// Clean up empty session entries
if (Object.keys(state.bySession[sessionId]).length === 0) {
delete state.bySession[sessionId];
}
}
// Remove from pending
state.pendingForChat = state.pendingForChat.filter(
(id) => !idsToRemove.has(id)
);
// Clear editing if deleted
if (state.editingCommentId && idsToRemove.has(state.editingCommentId)) {
state.editingCommentId = null;
}
}),
getCommentsForFile: (sessionId: string, filePath: string): DiffComment[] => {
const state = get();
return state.bySession[sessionId]?.[filePath] ?? EMPTY_COMMENTS;
},
getPendingComments: (): DiffComment[] => {
const state = get();
const pending: DiffComment[] = [];
for (const sessionId of Object.keys(state.bySession)) {
for (const filePath of Object.keys(state.bySession[sessionId])) {
for (const comment of state.bySession[sessionId][filePath]) {
if (state.pendingForChat.includes(comment.id)) {
pending.push(comment);
}
}
}
}
return pending;
},
clearSessionComments: (sessionId: string) =>
set((state) => {
// Collect comment IDs BEFORE deleting the session
const sessionCommentIds = new Set<string>();
if (state.bySession[sessionId]) {
for (const filePath of Object.keys(state.bySession[sessionId])) {
for (const comment of state.bySession[sessionId][filePath]) {
sessionCommentIds.add(comment.id);
}
}
}
// Now delete the session and filter pending
delete state.bySession[sessionId];
state.pendingForChat = state.pendingForChat.filter(
(id) => !sessionCommentIds.has(id)
);
}),
})),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
bySession: state.bySession,
// Don't persist pendingForChat or editingCommentId
}),
}
)
);
/**
* Hook to get comments for a specific file.
* Returns stable reference from store (or stable empty array).
*/
export function useFileComments(sessionId: string, filePath: string) {
const fileComments = useDiffCommentsStore((state) =>
state.bySession[sessionId]?.[filePath]
);
return fileComments ?? EMPTY_COMMENTS;
}
/**
* Hook to get pending comments for chat.
* Uses useMemo with stable store subscriptions to prevent infinite re-renders.
*/
export function usePendingComments() {
const bySession = useDiffCommentsStore((state) => state.bySession);
const pendingForChat = useDiffCommentsStore((state) => state.pendingForChat);
return useMemo(() => {
if (pendingForChat.length === 0) return EMPTY_COMMENTS;
const pending: DiffComment[] = [];
for (const sessionId of Object.keys(bySession)) {
for (const filePath of Object.keys(bySession[sessionId])) {
for (const comment of bySession[sessionId][filePath]) {
if (pendingForChat.includes(comment.id)) {
pending.push(comment);
}
}
}
}
return pending;
}, [bySession, pendingForChat]);
}
/**
* Hook to get pending comments grouped by file.
* Uses useMemo with stable store subscriptions to prevent infinite re-renders.
*/
export function usePendingCommentsByFile() {
const bySession = useDiffCommentsStore((state) => state.bySession);
const pendingForChat = useDiffCommentsStore((state) => state.pendingForChat);
return useMemo(() => {
if (pendingForChat.length === 0) return EMPTY_COMMENTS_BY_FILE;
const byFile: Record<string, DiffComment[]> = {};
for (const sessionId of Object.keys(bySession)) {
for (const filePath of Object.keys(bySession[sessionId])) {
for (const comment of bySession[sessionId][filePath]) {
if (pendingForChat.includes(comment.id)) {
if (!byFile[filePath]) {
byFile[filePath] = [];
}
byFile[filePath].push(comment);
}
}
}
}
return byFile;
}, [bySession, pendingForChat]);
}
@@ -0,0 +1,6 @@
export {
useDiffCommentsStore,
useFileComments,
usePendingComments,
usePendingCommentsByFile,
} from './diff-comments-slice';
@@ -3,6 +3,17 @@ import type { SessionRuntimeSlice, SessionRuntimeSliceState } from './types';
const maxProcessOutputBytes = 2 * 1024 * 1024;
/** Helper to compare two string arrays for equality */
function arraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function trimProcessOutput(value: string) {
if (value.length <= maxProcessOutputBytes) {
return value;
@@ -83,6 +94,23 @@ export const createSessionRuntimeSlice: StateCreator<
}),
setGitStatus: (sessionId, gitStatus) =>
set((draft) => {
const existing = draft.gitStatus.bySessionId[sessionId];
// Skip update if git status content hasn't meaningfully changed
// Compare branch, file counts, and modified/added/deleted arrays
if (existing) {
const sameContent =
existing.branch === gitStatus.branch &&
existing.ahead === gitStatus.ahead &&
existing.behind === gitStatus.behind &&
arraysEqual(existing.modified, gitStatus.modified) &&
arraysEqual(existing.added, gitStatus.added) &&
arraysEqual(existing.deleted, gitStatus.deleted) &&
arraysEqual(existing.untracked, gitStatus.untracked) &&
arraysEqual(existing.renamed, gitStatus.renamed);
if (sameContent) {
return; // Skip update - no meaningful changes
}
}
draft.gitStatus.bySessionId[sessionId] = gitStatus;
}),
clearGitStatus: (sessionId) =>
+1 -2
View File
@@ -29,8 +29,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@git-diff-view/file": "^0.0.36",
"@git-diff-view/react": "^0.0.36",
"@kandev/theme": "workspace:*",
"@kandev/types": "workspace:*",
"@kandev/ui": "workspace:*",
@@ -44,6 +42,7 @@
"@milkdown/react": "^7.18.0",
"@milkdown/theme-nord": "^7.18.0",
"@milkdown/utils": "^7.18.0",
"@pierre/diffs": "^1.0.10",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",