diff --git a/web/package-lock.json b/web/package-lock.json index 4d71a4a..1f53d74 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -53,7 +53,8 @@ "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.4" } }, "node_modules/@antfu/ni": { @@ -4377,6 +4378,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4489,6 +4501,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4912,6 +4931,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.10.1", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", @@ -5108,6 +5240,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -5332,6 +5474,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6258,6 +6410,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6551,6 +6710,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6627,6 +6796,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -9457,6 +9636,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9732,6 +9922,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10785,6 +10982,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10845,6 +11049,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10855,6 +11066,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -11063,6 +11281,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -11090,6 +11315,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", @@ -11684,6 +11919,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -11716,6 +12041,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 49485cd..c38d2e9 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@codemirror/commands": "^6.10.2", @@ -55,6 +57,7 @@ "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.4" } } diff --git a/web/src/components/ui/context-menu.tsx b/web/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..7b41e8a --- /dev/null +++ b/web/src/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/web/src/lib/config-files.test.ts b/web/src/lib/config-files.test.ts new file mode 100644 index 0000000..4e0411b --- /dev/null +++ b/web/src/lib/config-files.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { buildNewFilePath, splitDirAndBase, validateNewFileName } from './config-files'; + +describe('buildNewFilePath', () => { + it('returns the trimmed name when dir is empty (root)', () => { + expect(buildNewFilePath('', 'example.md')).toBe('example.md'); + }); + + it('prefixes the directory with a slash separator', () => { + expect(buildNewFilePath('agents', 'new-agent.md')).toBe('agents/new-agent.md'); + }); + + it('trims surrounding whitespace from the name', () => { + expect(buildNewFilePath('agents', ' new.md ')).toBe('agents/new.md'); + }); + + it('returns empty string when name is empty or whitespace', () => { + expect(buildNewFilePath('agents', '')).toBe(''); + expect(buildNewFilePath('agents', ' ')).toBe(''); + expect(buildNewFilePath('', '')).toBe(''); + }); + + it('handles nested directory prefixes verbatim', () => { + expect(buildNewFilePath('agents/sub', 'x.md')).toBe('agents/sub/x.md'); + }); + + it('strips a leading "./" from the name', () => { + expect(buildNewFilePath('', './foo.md')).toBe('foo.md'); + expect(buildNewFilePath('agents', './foo.md')).toBe('agents/foo.md'); + }); +}); + +describe('splitDirAndBase', () => { + it('returns empty dir for a root-level path', () => { + expect(splitDirAndBase('file.md')).toEqual({ dir: '', base: 'file.md' }); + }); + + it('splits a nested path on the last slash', () => { + expect(splitDirAndBase('agents/a.md')).toEqual({ dir: 'agents', base: 'a.md' }); + expect(splitDirAndBase('a/b/c.md')).toEqual({ dir: 'a/b', base: 'c.md' }); + }); + + it('handles an empty string', () => { + expect(splitDirAndBase('')).toEqual({ dir: '', base: '' }); + }); +}); + +describe('validateNewFileName', () => { + const empty = new Set(); + + it('returns null for empty input (not yet ready, not an error)', () => { + expect(validateNewFileName('', '', empty)).toBeNull(); + expect(validateNewFileName('agents', ' ', empty)).toBeNull(); + }); + + it('accepts a simple valid name at the root', () => { + expect(validateNewFileName('', 'new.md', empty)).toBeNull(); + }); + + it('accepts a simple valid name inside a folder', () => { + expect(validateNewFileName('agents', 'new.md', empty)).toBeNull(); + }); + + it('rejects names starting with a slash (absolute path)', () => { + expect(validateNewFileName('', '/etc/passwd', empty)).toBe('invalid-path'); + expect(validateNewFileName('agents', '/nope.md', empty)).toBe('invalid-path'); + }); + + it('rejects names containing ".." (path traversal)', () => { + expect(validateNewFileName('', '../escape.md', empty)).toBe('invalid-path'); + expect(validateNewFileName('agents', '..', empty)).toBe('invalid-path'); + expect(validateNewFileName('agents', 'sub/../other.md', empty)).toBe('invalid-path'); + }); + + it('rejects a name that would collide with an existing root file', () => { + const existing = new Set(['config.md', 'agents/a.md']); + expect(validateNewFileName('', 'config.md', existing)).toBe('already-exists'); + }); + + it('rejects a name that would collide with an existing nested file', () => { + const existing = new Set(['agents/a.md']); + expect(validateNewFileName('agents', 'a.md', existing)).toBe('already-exists'); + }); + + it('does not treat same-basename-in-different-dir as a collision', () => { + const existing = new Set(['agents/a.md']); + expect(validateNewFileName('', 'a.md', existing)).toBeNull(); + expect(validateNewFileName('other', 'a.md', existing)).toBeNull(); + }); + + it('ignores surrounding whitespace when checking collisions', () => { + const existing = new Set(['config.md']); + expect(validateNewFileName('', ' config.md ', existing)).toBe('already-exists'); + }); + + it('accepts a leading "./" and normalizes it for collision checks', () => { + const existing = new Set(['config.md', 'agents/a.md']); + expect(validateNewFileName('', './new.md', existing)).toBeNull(); + expect(validateNewFileName('', './config.md', existing)).toBe('already-exists'); + expect(validateNewFileName('agents', './a.md', existing)).toBe('already-exists'); + }); +}); diff --git a/web/src/lib/config-files.ts b/web/src/lib/config-files.ts new file mode 100644 index 0000000..bdd2829 --- /dev/null +++ b/web/src/lib/config-files.ts @@ -0,0 +1,48 @@ +// Pure helpers for the Config tab "add file" flow. +// Extracted so they can be unit-tested without mounting the full page. + +/** + * Strip a leading `./` from a user-typed name so `./foo.md` and `foo.md` + * are treated identically (both for path building and collision checks). + */ +function stripDotSlash(name: string): string { + return name.startsWith('./') ? name.slice(2) : name; +} + +/** + * Build the full path for a new config file given a directory prefix + * (empty string = root) and a user-typed name. Trims whitespace and a + * leading `./`; does not validate — use `validateNewFileName` for that. + */ +export function buildNewFilePath(dir: string, rawName: string): string { + const name = stripDotSlash(rawName.trim()); + if (!name) return ''; + return dir ? `${dir}/${name}` : name; +} + +export type NewFileError = 'invalid-path' | 'already-exists'; + +/** + * Validate a user-typed filename against the set of existing file paths. + * Returns null when input is acceptable (or empty — callers decide how + * to treat the empty case). An empty name is treated as "not yet ready" + * rather than an error so the UI doesn't flash red before typing. + */ +export function validateNewFileName( + dir: string, + rawName: string, + existingPaths: ReadonlySet, +): NewFileError | null { + const name = stripDotSlash(rawName.trim()); + if (!name) return null; + if (name.startsWith('/') || name.includes('..')) return 'invalid-path'; + const full = buildNewFilePath(dir, rawName); + if (existingPaths.has(full)) return 'already-exists'; + return null; +} + +/** Split a file path into its parent directory and basename. */ +export function splitDirAndBase(path: string): { dir: string; base: string } { + const i = path.lastIndexOf('/'); + return i < 0 ? { dir: '', base: path } : { dir: path.slice(0, i), base: path.slice(i + 1) }; +} diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index d233a1a..f6758d6 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -4,8 +4,30 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { listConfigFiles, getConfigFile, writeConfigFile, reloadConfig, validateConfig, getInstance } from '@/api/client'; import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { cn } from '@/lib/utils'; -import { FileCode, Save, RefreshCw, Undo2, CheckCircle, X, ChevronRight, ChevronDown, Eye, EyeOff, Code } from 'lucide-react'; +import { buildNewFilePath, splitDirAndBase, validateNewFileName } from '@/lib/config-files'; + +const NEW_FILE_ERROR_MESSAGES = { + 'invalid-path': 'Invalid path', + 'already-exists': 'File already exists', +} as const; + +// Shared styling for the two ContextMenu popovers in this page. The project's +// --radius token is ~18px, which makes the stock shadcn menu render as a +// pill; tighten corners here only. +const MENU_CONTENT_CLASS = + '!rounded-[6px] min-w-[11rem] [&_[data-slot=context-menu-item]]:!rounded-[4px]'; +const MENU_CONTENT_CLASS_WITH_LABEL = `${MENU_CONTENT_CLASS} [&_[data-slot=context-menu-label]]:!rounded-[4px]`; +const preventDefault = (e: Event) => e.preventDefault(); +import { FileCode, FilePlus, Save, RefreshCw, Undo2, CheckCircle, X, ChevronRight, ChevronDown, Eye, EyeOff, Code } from 'lucide-react'; import { MarkdownPreview } from '@/components/MarkdownPreview'; import { useHorizontalResize } from '@/hooks/use-horizontal-resize'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, gutter, GutterMarker } from '@codemirror/view'; @@ -219,6 +241,9 @@ export function ConfigPage() { const [validating, setValidating] = useState(false); const [reloading, setReloading] = useState(false); const [showDiff, setShowDiff] = useState(false); + const [addingFile, setAddingFile] = useState<{ dir: string } | null>(null); + const [newFileName, setNewFileName] = useState(''); + const [creatingFile, setCreatingFile] = useState(false); const [codeOpen, setCodeOpen] = useState(true); const [previewOpen, setPreviewOpen] = useState(false); const [previewContent, setPreviewContent] = useState(''); @@ -236,6 +261,7 @@ export function ConfigPage() { const editorRef = useRef(null); const viewRef = useRef(null); + const addFileInputRef = useRef(null); const { data: instance } = useQuery({ queryKey: ['instance', id], @@ -476,6 +502,62 @@ export function ConfigPage() { } }, [id, reloading, selectedFile, queryClient]); + const existingNames = useMemo( + () => new Set((fileList?.files ?? []).map(f => f.name)), + [fileList], + ); + + const trimmedNewName = newFileName.trim(); + const newFileFullPath = addingFile ? buildNewFilePath(addingFile.dir, newFileName) : ''; + const newFileErrorCode = addingFile + ? validateNewFileName(addingFile.dir, newFileName, existingNames) + : null; + const newFileError = newFileErrorCode ? NEW_FILE_ERROR_MESSAGES[newFileErrorCode] : null; + + // Radix ContextMenu would normally restore focus to its trigger when the + // menu closes; each ContextMenuContent below calls preventDefault on + // onCloseAutoFocus so this focus call isn't clobbered. + useEffect(() => { + if (addingFile) addFileInputRef.current?.focus(); + }, [addingFile]); + + const startAddingFile = useCallback((dir: string) => { + setNewFileName(''); + setAddingFile({ dir }); + if (dir) { + setOpenDirs(prev => { + if (prev.has(dir)) return prev; + const next = new Set(prev); + next.add(dir); + return next; + }); + } + }, []); + + const cancelAddingFile = useCallback(() => { + setAddingFile(null); + setNewFileName(''); + }, []); + + const handleCreateFile = useCallback(async () => { + if (!id || creatingFile || !addingFile) return; + if (!trimmedNewName || newFileError) return; + const name = newFileFullPath; + setCreatingFile(true); + try { + await writeConfigFile(id, name, ''); + toast.success(`Created ${name}`); + cancelAddingFile(); + await queryClient.invalidateQueries({ queryKey: ['configFiles', id] }); + setSelectedFile(name); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to create file'; + toast.error('Create failed', { description: msg }); + } finally { + setCreatingFile(false); + } + }, [id, creatingFile, addingFile, newFileFullPath, trimmedNewName, newFileError, queryClient, cancelAddingFile]); + const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); resizing.current = true; @@ -588,16 +670,25 @@ export function ConfigPage() { + {/* Content */}
{/* File tree */} -
-
+ + +
+
{(() => { // Sort files: root files first, then grouped by directory const sorted = [...fileList.files].sort((a, b) => { - const aDir = a.name.includes('/') ? a.name.substring(0, a.name.lastIndexOf('/')) : ''; - const bDir = b.name.includes('/') ? b.name.substring(0, b.name.lastIndexOf('/')) : ''; + const aDir = splitDirAndBase(a.name).dir; + const bDir = splitDirAndBase(b.name).dir; if (aDir === bDir) return a.name.localeCompare(b.name); if (aDir === '') return -1; if (bDir === '') return 1; @@ -608,7 +699,7 @@ export function ConfigPage() { const rootFiles: typeof sorted = []; const dirGroups = new Map(); for (const file of sorted) { - const dir = file.name.includes('/') ? file.name.substring(0, file.name.lastIndexOf('/')) : ''; + const dir = splitDirAndBase(file.name).dir; if (!dir) { rootFiles.push(file); } else { @@ -628,11 +719,16 @@ export function ConfigPage() { const renderFile = (file: typeof sorted[0], _indented?: boolean) => { const isModified = pendingChanges.has(file.name); - const basename = file.name.includes('/') ? file.name.substring(file.name.lastIndexOf('/') + 1) : file.name; + const basename = splitDirAndBase(file.name).base; return ( - {isOpen && ( + + + + + + + {dir} + + + startAddingFile(dir)}> + + Add file to folder + + + + {(isOpen || isAddingHere) && (
{files.map(f => renderFile(f, true))} + {isAddingHere && renderInlineInput()}
)}
@@ -677,8 +826,16 @@ export function ConfigPage() { ); })()} -
-
+
+
+ + + startAddingFile('')}> + + Add file + + + {/* Resize handle */}