diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go index 5b2005d9db..0b8473c903 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -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) } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go index 6b3762659f..74646aefe1 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go @@ -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) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go index 99bf5775e9..d3d563468c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go @@ -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 "" } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go index c3a1a82115..d713f71945 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go @@ -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) } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go index 6ca553e5f9..95450c7389 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go @@ -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") } }) diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 37c5c38c62..85d0707d40 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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 diff --git a/apps/web/app/demo/git-diff-view/demo-client.tsx b/apps/web/app/demo/git-diff-view/demo-client.tsx deleted file mode 100644 index c2659324ac..0000000000 --- a/apps/web/app/demo/git-diff-view/demo-client.tsx +++ /dev/null @@ -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 ( - - ); -}`; - -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 ( - - ); -}`; - -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('button'); - const currentDiff = DIFFS[selectedDiff]; - - return ( -
-
- {/* Header */} -
-

GitDiffViewer Demo

-

- Diff viewer with inline comments and multi-line selection. -

-
- - {/* Diff selector */} -
- {Object.entries(DIFFS).map(([key, { label }]) => ( - - ))} -
- - {/* Description */} -
- {currentDiff.description} -
- - {/* Instructions */} -
-

- Single line: Click the{' '} - + button on any line to add a - comment. -

-

- Multi-line: Click and drag from one line to another to select a range, - then add your comment. -

-
- - {/* Diff viewer */} - - - {/* Features list */} -
-

Features:

-
-
-

Built-in Features

-
    -
  • Syntax highlighting
  • -
  • Split and unified view modes
  • -
  • Light and dark theme support
  • -
  • Add widget (+) buttons on hover
  • -
-
-
-

Comments

-
    -
  • Click + to open inline comment widget
  • -
  • Click and drag to select multiple lines
  • -
  • Visual overlay shows selected range
  • -
  • Comments persisted to localStorage
  • -
-
-
-
-
-
- ); -} - -export function GitDiffViewDemo() { - return ( - - - - - - ); -} diff --git a/apps/web/app/demo/git-diff-view/page.tsx b/apps/web/app/demo/git-diff-view/page.tsx deleted file mode 100644 index 08be150161..0000000000 --- a/apps/web/app/demo/git-diff-view/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { GitDiffViewDemo } from './demo-client'; - -export const metadata = { - title: 'GitDiffViewer Demo', -}; - -export default function GitDiffViewDemoPage() { - return ; -} diff --git a/apps/web/components/diff/comment-display.tsx b/apps/web/components/diff/comment-display.tsx new file mode 100644 index 0000000000..fb541ee00d --- /dev/null +++ b/apps/web/components/diff/comment-display.tsx @@ -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 ( +
+ +
+ {lineRange} + · + {comment.annotation} +
+ {onDelete && ( + + )} +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + {lineRange} + + + ({comment.side === 'additions' ? 'new' : 'old'}) + +
+
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ + {/* Code preview */} + {showCode && comment.codeContent && ( +
+          {comment.codeContent}
+        
+ )} + + {/* Comment text */} +

+ {comment.annotation} +

+
+ ); +} diff --git a/apps/web/components/diff/comment-form.tsx b/apps/web/components/diff/comment-form.tsx new file mode 100644 index 0000000000..67978c4d18 --- /dev/null +++ b/apps/web/components/diff/comment-form.tsx @@ -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(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 ( +
+