feat: pierre diffs lib (#147)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+32
-14
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Generated
+159
-100
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user