diff --git a/package-lock.json b/package-lock.json index da7b1325..4435eea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "express": "^4.18.2", @@ -3463,6 +3464,447 @@ "node": ">=14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@release-it/conventional-changelog": { "version": "10.0.5", "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.5.tgz", @@ -4111,7 +4553,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -5084,6 +5526,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -6186,6 +6640,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -6958,6 +7428,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -8674,6 +9150,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -14063,6 +14548,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "6.30.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", @@ -14095,6 +14627,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.6", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", @@ -17592,6 +18146,49 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/package.json b/package.json index ab77f41d..32033484 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "express": "^4.18.2", diff --git a/shared/modelConstants.js b/shared/modelConstants.js index fd47010b..90b973ed 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -51,7 +51,7 @@ export const CURSOR_MODELS = { { value: "grok", label: "Grok" }, ], - DEFAULT: "gpt-5-3-codex", + DEFAULT: "gpt-5.3-codex", }; /** @@ -94,3 +94,13 @@ export const GEMINI_MODELS = { DEFAULT: "gemini-3.1-pro-preview", }; + +/** + * Ordered provider registry. Display order in selection UIs. + */ +export const PROVIDERS = [ + { id: "claude", name: "Anthropic", models: CLAUDE_MODELS }, + { id: "codex", name: "OpenAI", models: CODEX_MODELS }, + { id: "gemini", name: "Google", models: GEMINI_MODELS }, + { id: "cursor", name: "Cursor", models: CURSOR_MODELS }, +]; diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 63c11df0..432447e4 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -1,14 +1,25 @@ import { useEffect, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; + import Sidebar from '../sidebar/view/Sidebar'; import MainContent from '../main-content/view/MainContent'; +import CommandPalette from '../command-palette/CommandPalette'; import { useWebSocket } from '../../contexts/WebSocketContext'; +import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/PaletteOpsContext'; import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; export default function AppContent() { + return ( + + + + ); +} + +function AppContentInner() { const navigate = useNavigate(); const { sessionId } = useParams<{ sessionId?: string }>(); const { t } = useTranslation('common'); @@ -40,6 +51,7 @@ export default function AppContent() { openSettings, refreshProjectsSilently, sidebarSharedProps, + handleNewSession, } = useProjectsState({ sessionId, navigate, @@ -48,27 +60,10 @@ export default function AppContent() { activeSessions, }); - useEffect(() => { - // Expose a non-blocking refresh for chat/session flows. - // Full loading refreshes are still available through direct fetchProjects calls. - window.refreshProjects = refreshProjectsSilently; - - return () => { - if (window.refreshProjects === refreshProjectsSilently) { - delete window.refreshProjects; - } - }; - }, [refreshProjectsSilently]); - - useEffect(() => { - window.openSettings = openSettings; - - return () => { - if (window.openSettings === openSettings) { - delete window.openSettings; - } - }; - }, [openSettings]); + usePaletteOpsRegister({ + openSettings, + refreshProjects: refreshProjectsSilently, + }); useEffect(() => { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { @@ -202,6 +197,12 @@ export default function AppContent() { /> + openSettings()} + onShowTab={setActiveTab} + /> ); } diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 73d1a5e7..855ee788 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { PendingPermissionRequest } from '../types/types'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -99,6 +100,7 @@ export function useChatRealtimeHandlers({ onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { + const paletteOps = usePaletteOps(); const lastProcessedMessageRef = useRef(null); useEffect(() => { @@ -280,9 +282,7 @@ export function useChatRealtimeHandlers({ onNavigateToSession?.(actualId); } sessionStorage.removeItem('pendingSessionId'); - if (window.refreshProjects) { - setTimeout(() => window.refreshProjects?.(), 500); - } + setTimeout(() => { void paletteOps.refreshProjects(); }, 500); } break; } @@ -365,5 +365,6 @@ export function useChatRealtimeHandlers({ onNavigateToSession, onWebSocketReconnect, sessionStore, + paletteOps, ]); } diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 04569e60..3f82438f 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Check, ChevronDown } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { useServerPlatform } from "../../../../hooks/useServerPlatform"; import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; @@ -9,6 +9,7 @@ import { CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, + PROVIDERS, } from "../../../../../shared/modelConstants"; import type { ProjectSession, LLMProvider } from "../../../../types/app"; import { NextTaskBanner } from "../../../task-master"; @@ -26,6 +27,9 @@ import { Card, } from "../../../../shared/view/ui"; +const MOD_KEY = + typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"; + type ProviderSelectionEmptyStateProps = { selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -52,12 +56,11 @@ type ProviderGroup = { models: { value: string; label: string }[]; }; -const PROVIDER_GROUPS: ProviderGroup[] = [ - { id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS }, - { id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS }, - { id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS }, - { id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS }, -]; +const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({ + id: p.id as LLMProvider, + name: p.name, + models: p.models.OPTIONS, +})); function getModelConfig(p: LLMProvider) { if (p === "claude") return CLAUDE_MODELS; @@ -231,9 +234,14 @@ export default function ProviderSelectionEmptyState({ defaultValue: "No models found.", })} - {visibleProviderGroups.map((group) => ( + {visibleProviderGroups.map((group, idx) => ( 0 + ? "border-t border-border/40 [&_[cmdk-group-heading]]:mt-1 [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider" + : "[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider" + } heading={ @@ -248,6 +256,7 @@ export default function ProviderSelectionEmptyState({ key={`${group.id}-${model.value}`} value={`${group.name} ${model.label}`} onSelect={() => handleModelSelect(group.id, model.value)} + className="ml-4 border-l border-border/40 pl-4" > {model.label} {isSelected && ( @@ -282,6 +291,18 @@ export default function ProviderSelectionEmptyState({ }

+

+ + ), + }} + /> +

+ {provider && tasksEnabled && isTaskMasterInstalled && (
setMarkdownPreview((previous) => !previous)} - onOpenSettings={() => window.openSettings?.('appearance')} + onOpenSettings={() => paletteOps.openSettings('appearance')} onDownload={handleDownload} onSave={handleSave} onToggleFullscreen={() => setIsFullscreen((previous) => !previous)} diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 00000000..db3bd87b --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,373 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ArrowDownToLine, + ArrowUpFromLine, + ChevronRight, + FileText, + GitCommit, + GitMerge, + MessageSquare, + MessageSquarePlus, + RefreshCw, + Settings, + SunMoon, + X, +} from 'lucide-react'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Dialog, + DialogContent, + DialogTitle, +} from '../../shared/view/ui'; +import { useTheme } from '../../contexts/ThemeContext'; +import { usePaletteOps } from '../../contexts/PaletteOpsContext'; +import { SETTINGS_MAIN_TABS } from '../settings/constants/constants'; +import type { AppTab, Project } from '../../types/app'; + +import { useSessionsSource } from './sources/useSessionsSource'; +import { useFilesSource } from './sources/useFilesSource'; +import { useCommitsSource } from './sources/useCommitsSource'; +import { useSessionMessageSearch } from './sources/useSessionMessageSearch'; +import { useBranchesSource } from './sources/useBranchesSource'; +import { useGitActions } from './sources/useGitActions'; + +type Page = 'actions' | 'files' | 'sessions' | 'commits' | 'branches'; + +const PAGE_LABELS: Record = { + actions: 'Actions', + files: 'Files', + sessions: 'Sessions', + commits: 'Commits', + branches: 'Branches', +}; + +type CommandPaletteProps = { + selectedProject: Project | null; + onStartNewChat: (project: Project) => void; + onOpenSettings: (tab?: string) => void; + onShowTab?: (tab: AppTab) => void; +}; + +const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [ + { id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' }, + { id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' }, + { id: 'shell', label: 'Go to Shell', keywords: 'shell terminal console' }, + { id: 'git', label: 'Go to Git', keywords: 'git diff branches' }, + { id: 'tasks', label: 'Go to Tasks', keywords: 'tasks taskmaster' }, +]; + +export default function CommandPalette({ + selectedProject, + onStartNewChat, + onOpenSettings, + onShowTab, +}: CommandPaletteProps) { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + const [pages, setPages] = React.useState([]); + const { toggleDarkMode } = useTheme(); + const navigate = useNavigate(); + const ops = usePaletteOps(); + + const page = pages.at(-1); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isCmdK = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k'; + if (!isCmdK) return; + e.preventDefault(); + setOpen((prev) => !prev); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + React.useEffect(() => { + if (!open) { + setSearch(''); + setPages([]); + } + }, [open]); + + const projectId = selectedProject?.projectId; + + const showActions = !page || page === 'actions'; + const showSessions = !page || page === 'sessions'; + const showFiles = !page || page === 'files'; + const showCommits = !page || page === 'commits'; + const showBranches = !page || page === 'branches' || page === 'actions'; + + const sessions = useSessionsSource(projectId, open && showSessions); + const messageMatches = useSessionMessageSearch(projectId, search, open && showSessions); + const files = useFilesSource(projectId, open && showFiles); + const commits = useCommitsSource(projectId, open && showCommits); + const branches = useBranchesSource(projectId, open && showBranches); + const git = useGitActions(projectId); + + const sessionRows = React.useMemo(() => { + if (!showSessions) return []; + type Row = { id: string; label: string; provider?: string; snippet?: string }; + const byId = new Map(); + for (const s of sessions) { + byId.set(s.id, { id: s.id, label: s.label, provider: s.provider }); + } + for (const m of messageMatches) { + const existing = byId.get(m.sessionId); + if (existing) { + existing.snippet = m.snippet; + } else { + byId.set(m.sessionId, { + id: m.sessionId, + label: m.label, + provider: m.provider, + snippet: m.snippet, + }); + } + } + return Array.from(byId.values()); + }, [sessions, messageMatches, showSessions]); + + const run = React.useCallback((fn: () => void) => { + setOpen(false); + fn(); + }, []); + + const pushPage = React.useCallback((next: Page) => { + setSearch(''); + setPages((prev) => [...prev, next]); + }, []); + + const popPage = React.useCallback(() => { + setSearch(''); + setPages((prev) => prev.slice(0, -1)); + }, []); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !search && pages.length > 0) { + e.preventDefault(); + popPage(); + } + }, [search, pages.length, popPage]); + + const startNewChatDisabled = !selectedProject; + const browseLimit = 5; + const filesShown = page === 'files' ? files : files.slice(0, browseLimit); + const commitsShown = page === 'commits' ? commits : commits.slice(0, browseLimit); + const sessionsShown = page === 'sessions' ? sessionRows : sessionRows.slice(0, browseLimit); + const branchesShown = page === 'branches' ? branches : branches.slice(0, browseLimit); + + return ( + + + Command palette + + {page && ( +
+ + {PAGE_LABELS[page]} + + + Backspace to go back +
+ )} + + + No results. + + {showActions && ( + + { + if (!selectedProject) return; + run(() => onStartNewChat(selectedProject)); + }} + > + + Start new chat + {startNewChatDisabled && ( + Select a project first + )} + + run(() => onOpenSettings())}> + + Open settings + + run(toggleDarkMode)}> + + Toggle theme + + + )} + + {showActions && ( + + {NAV_TABS.map((tab) => ( + run(() => onShowTab?.(tab.id))} + > + {tab.label} + + ))} + + )} + + {showActions && projectId && ( + + run(() => { void git.fetch(); onShowTab?.('git'); })} + > + + Git: Fetch + + run(() => { void git.pull(); onShowTab?.('git'); })} + > + + Git: Pull + + run(() => { void git.push(); onShowTab?.('git'); })} + > + + Git: Push + + + )} + + {showActions && ( + + {SETTINGS_MAIN_TABS.map(({ id, label, keywords, icon: Icon }) => ( + run(() => onOpenSettings(id))} + > + + Settings: {label} + + ))} + + )} + + {showSessions && projectId && sessionsShown.length > 0 && ( + + {sessionsShown.map((s) => ( + run(() => navigate(`/session/${s.id}`))} + > + +
+ {s.label} + {s.snippet && ( + {s.snippet} + )} +
+ {s.provider && ( + {s.provider} + )} +
+ ))} + {!page && sessionRows.length > browseLimit && ( + pushPage('sessions')} /> + )} +
+ )} + + {showFiles && projectId && filesShown.length > 0 && ( + + {filesShown.map((f) => ( + run(() => ops.openFile(f.path))} + > + + {f.name} + {f.path} + + ))} + {!page && files.length > browseLimit && ( + pushPage('files')} /> + )} + + )} + + {showCommits && projectId && commitsShown.length > 0 && ( + + {commitsShown.map((c) => ( + run(() => onShowTab?.('git'))} + > + + {c.shortHash} + {c.message} + {c.author} + + ))} + {!page && commits.length > browseLimit && ( + pushPage('commits')} /> + )} + + )} + + {showBranches && projectId && branchesShown.length > 0 && ( + + {branchesShown.map((b) => ( + run(() => { void git.checkout(b.name); onShowTab?.('git'); })} + > + + Switch to: {b.name} + + ))} + {!page && branches.length > browseLimit && ( + pushPage('branches')} /> + )} + + )} +
+
+
+
+ ); +} + +function BrowseAllItem({ label, onSelect }: { label: string; onSelect: () => void }) { + return ( + + + {label} + + ); +} diff --git a/src/components/command-palette/sources/useApiSource.ts b/src/components/command-palette/sources/useApiSource.ts new file mode 100644 index 00000000..979a8914 --- /dev/null +++ b/src/components/command-palette/sources/useApiSource.ts @@ -0,0 +1,37 @@ +import { useEffect, useState, type DependencyList } from 'react'; + +export function useApiSource(opts: { + enabled: boolean; + deps: DependencyList; + fetcher: (signal: AbortSignal) => Promise; + parse: (raw: R) => T[]; +}): T[] { + const [items, setItems] = useState([]); + const { enabled, deps, fetcher, parse } = opts; + + useEffect(() => { + if (!enabled) { + setItems([]); + return; + } + + const controller = new AbortController(); + + fetcher(controller.signal) + .then((r) => r.json() as Promise) + .then((data) => { + if (controller.signal.aborted) return; + setItems(parse(data)); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + if (err instanceof DOMException && err.name === 'AbortError') return; + setItems([]); + }); + + return () => controller.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, ...deps]); + + return items; +} diff --git a/src/components/command-palette/sources/useBranchesSource.ts b/src/components/command-palette/sources/useBranchesSource.ts new file mode 100644 index 00000000..33a30870 --- /dev/null +++ b/src/components/command-palette/sources/useBranchesSource.ts @@ -0,0 +1,21 @@ +import { authenticatedFetch } from '../../../utils/api'; + +import { useApiSource } from './useApiSource'; + +export type BranchResult = { name: string }; + +interface BranchesResponse { + localBranches?: string[]; +} + +export function useBranchesSource(projectId: string | undefined, enabled: boolean) { + return useApiSource({ + enabled: enabled && !!projectId, + deps: [projectId], + fetcher: (signal) => { + const params = new URLSearchParams({ project: projectId! }); + return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal }); + }, + parse: (data) => (data.localBranches ?? []).map((name) => ({ name })), + }); +} diff --git a/src/components/command-palette/sources/useCommitsSource.ts b/src/components/command-palette/sources/useCommitsSource.ts new file mode 100644 index 00000000..e173fa7f --- /dev/null +++ b/src/components/command-palette/sources/useCommitsSource.ts @@ -0,0 +1,35 @@ +import { authenticatedFetch } from '../../../utils/api'; + +import { useApiSource } from './useApiSource'; + +export type CommitResult = { + hash: string; + shortHash: string; + message: string; + author: string; +}; + +interface CommitsResponse { + commits?: Array<{ hash: string; message: string; author: string }>; + error?: string; +} + +export function useCommitsSource(projectId: string | undefined, enabled: boolean) { + return useApiSource({ + enabled: enabled && !!projectId, + deps: [projectId], + fetcher: (signal) => { + const params = new URLSearchParams({ project: projectId!, limit: '50' }); + return authenticatedFetch(`/api/git/commits?${params.toString()}`, { signal }); + }, + parse: (data) => { + if (!data.commits) return []; + return data.commits.map((c) => ({ + hash: c.hash, + shortHash: c.hash.slice(0, 7), + message: c.message, + author: c.author, + })); + }, + }); +} diff --git a/src/components/command-palette/sources/useFilesSource.ts b/src/components/command-palette/sources/useFilesSource.ts new file mode 100644 index 00000000..e96b511a --- /dev/null +++ b/src/components/command-palette/sources/useFilesSource.ts @@ -0,0 +1,42 @@ +import { api } from '../../../utils/api'; + +import { useApiSource } from './useApiSource'; + +export type FileResult = { + path: string; + name: string; +}; + +interface FileNode { + type: 'file' | 'directory'; + name: string; + path: string; + children?: FileNode[]; +} + +const MAX_FILES = 500; + +function flatten(nodes: FileNode[], out: FileResult[]): void { + for (const node of nodes) { + if (out.length >= MAX_FILES) return; + if (node.type === 'file') { + out.push({ path: node.path, name: node.name }); + } else if (node.children && node.children.length > 0) { + flatten(node.children, out); + } + } +} + +export function useFilesSource(projectId: string | undefined, enabled: boolean) { + return useApiSource({ + enabled: enabled && !!projectId, + deps: [projectId], + fetcher: (signal) => api.getFiles(projectId!, { signal }), + parse: (data) => { + const tree: FileNode[] = Array.isArray(data) ? (data as FileNode[]) : []; + const flat: FileResult[] = []; + flatten(tree, flat); + return flat; + }, + }); +} diff --git a/src/components/command-palette/sources/useGitActions.ts b/src/components/command-palette/sources/useGitActions.ts new file mode 100644 index 00000000..cf765f34 --- /dev/null +++ b/src/components/command-palette/sources/useGitActions.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; + +import { authenticatedFetch } from '../../../utils/api'; + +async function postGit(path: string, body: Record) { + const res = await authenticatedFetch(path, { + method: 'POST', + body: JSON.stringify(body), + }); + return res.json(); +} + +export function useGitActions(projectId: string | undefined) { + const fetch = useCallback(() => { + if (!projectId) return Promise.resolve(); + return postGit('/api/git/fetch', { project: projectId }); + }, [projectId]); + + const pull = useCallback(() => { + if (!projectId) return Promise.resolve(); + return postGit('/api/git/pull', { project: projectId }); + }, [projectId]); + + const push = useCallback(() => { + if (!projectId) return Promise.resolve(); + return postGit('/api/git/push', { project: projectId }); + }, [projectId]); + + const checkout = useCallback( + (branch: string) => { + if (!projectId) return Promise.resolve(); + return postGit('/api/git/checkout', { project: projectId, branch }); + }, + [projectId], + ); + + return { fetch, pull, push, checkout }; +} diff --git a/src/components/command-palette/sources/useSessionMessageSearch.ts b/src/components/command-palette/sources/useSessionMessageSearch.ts new file mode 100644 index 00000000..f8a399eb --- /dev/null +++ b/src/components/command-palette/sources/useSessionMessageSearch.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; + +import { api } from '../../../utils/api'; +import type { LLMProvider } from '../../../types/app'; + +export type SessionMessageMatch = { + sessionId: string; + label: string; + snippet: string; + provider: LLMProvider; +}; + +type ProjectResult = { + projectId: string | null; + projectName: string; + sessions: Array<{ + sessionId: string; + provider: LLMProvider; + sessionSummary: string; + matches: Array<{ snippet: string }>; + }>; +}; + +const MIN_QUERY = 2; +const DEBOUNCE_MS = 250; + +export function useSessionMessageSearch( + projectId: string | undefined, + query: string, + enabled: boolean, +) { + const [items, setItems] = useState([]); + const seqRef = useRef(0); + const esRef = useRef(null); + + useEffect(() => { + const trimmed = query.trim(); + if (!enabled || !projectId || trimmed.length < MIN_QUERY) { + setItems([]); + esRef.current?.close(); + esRef.current = null; + return; + } + + esRef.current?.close(); + esRef.current = null; + seqRef.current++; + + const handle = setTimeout(() => { + const seq = ++seqRef.current; + const url = api.searchConversationsUrl(trimmed); + const es = new EventSource(url); + esRef.current = es; + const accumulated: SessionMessageMatch[] = []; + + es.addEventListener('result', (evt) => { + if (seq !== seqRef.current) { + es.close(); + return; + } + try { + const data = JSON.parse((evt as MessageEvent).data) as { projectResult: ProjectResult }; + const pr = data.projectResult; + if (pr.projectId !== projectId) return; + for (const s of pr.sessions) { + accumulated.push({ + sessionId: s.sessionId, + label: s.sessionSummary || s.sessionId, + snippet: s.matches[0]?.snippet ?? '', + provider: s.provider, + }); + } + setItems([...accumulated]); + } catch { + // ignore malformed + } + }); + + const finish = () => { + if (seq !== seqRef.current) return; + es.close(); + esRef.current = null; + }; + es.addEventListener('done', finish); + es.addEventListener('error', finish); + }, DEBOUNCE_MS); + + return () => { + clearTimeout(handle); + }; + }, [projectId, query, enabled]); + + useEffect(() => { + return () => { + esRef.current?.close(); + esRef.current = null; + }; + }, []); + + return items; +} diff --git a/src/components/command-palette/sources/useSessionsSource.ts b/src/components/command-palette/sources/useSessionsSource.ts new file mode 100644 index 00000000..9f20df58 --- /dev/null +++ b/src/components/command-palette/sources/useSessionsSource.ts @@ -0,0 +1,44 @@ +import { authenticatedFetch } from '../../../utils/api'; +import type { LLMProvider, ProjectSession } from '../../../types/app'; + +import { useApiSource } from './useApiSource'; + +export type SessionResult = { + id: string; + label: string; + provider?: LLMProvider; +}; + +interface SessionsResponse { + sessions?: ProjectSession[]; + cursorSessions?: ProjectSession[]; + codexSessions?: ProjectSession[]; + geminiSessions?: ProjectSession[]; +} + +export function useSessionsSource(projectId: string | undefined, enabled: boolean) { + return useApiSource({ + enabled: enabled && !!projectId, + deps: [projectId], + fetcher: (signal) => { + const params = new URLSearchParams({ limit: '50', offset: '0' }); + return authenticatedFetch( + `/api/projects/${encodeURIComponent(projectId!)}/sessions?${params.toString()}`, + { signal }, + ); + }, + parse: (data) => { + const all: ProjectSession[] = [ + ...(data.sessions ?? []), + ...(data.cursorSessions ?? []), + ...(data.codexSessions ?? []), + ...(data.geminiSessions ?? []), + ]; + return all.map((s) => ({ + id: s.id, + label: (s.title || s.summary || s.name || s.id) as string, + provider: s.__provider, + })); + }, + }); +} diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index bf0b87fc..a86dcbc9 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; + import ChatInterface from '../../chat/view/ChatInterface'; import FileTree from '../../file-tree/view/FileTree'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; @@ -6,12 +7,14 @@ import GitPanel from '../../git-panel/view/GitPanel'; import PluginTabContent from '../../plugins/view/PluginTabContent'; import type { MainContentProps } from '../types/types'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; +import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import EditorSidebar from '../../code-editor/view/EditorSidebar'; import type { Project } from '../../../types/app'; import { TaskMasterPanel } from '../../task-master'; + import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentStateView from './subcomponents/MainContentStateView'; import ErrorBoundary from './ErrorBoundary'; @@ -89,6 +92,13 @@ function MainContent({ } }, [shouldShowTasksTab, activeTab, setActiveTab]); + usePaletteOpsRegister({ + openFile: (filePath: string) => { + setActiveTab('files'); + handleFileOpen(filePath); + }, + }); + if (isLoading) { return ; } diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 429aabbe..e41da392 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -1,3 +1,15 @@ +import type { ComponentType } from 'react'; +import { + Bell, + Bot, + GitBranch, + Info, + KeyRound, + ListChecks, + Palette, + Plug, +} from 'lucide-react'; + import type { AgentCategory, AgentProvider, @@ -7,13 +19,22 @@ import type { SettingsMainTab, } from '../types/types'; -export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [ - 'agents', - 'appearance', - 'git', - 'api', - 'tasks', - 'notifications', +export type SettingsMainTabMeta = { + id: SettingsMainTab; + label: string; + keywords: string; + icon: ComponentType<{ className?: string }>; +}; + +export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [ + { id: 'agents', label: 'Agents', keywords: 'agents subagents claude code', icon: Bot }, + { id: 'appearance', label: 'Appearance', keywords: 'appearance theme dark light language', icon: Palette }, + { id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch }, + { id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound }, + { id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks }, + { id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell }, + { id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug }, + { id: 'about', label: 'About', keywords: 'about version info', icon: Info }, ]; export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini']; @@ -34,4 +55,3 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = { disallowedCommands: [], skipPermissions: false, }; - diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 514cf91b..d950bc43 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; +import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { DeleteProjectConfirmation, @@ -95,6 +96,7 @@ export function useSidebarController({ setSidebarVisible, sidebarVisible, }: UseSidebarControllerArgs) { + const paletteOps = usePaletteOps(); const [expandedProjects, setExpandedProjects] = useState>(new Set()); const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); @@ -536,11 +538,7 @@ export function useSidebarController({ try { const response = await api.renameProject(projectId, editingName); if (response.ok) { - if (window.refreshProjects) { - await window.refreshProjects(); - } else { - window.location.reload(); - } + await paletteOps.refreshProjects(); } else { console.error('Failed to rename project'); } @@ -551,7 +549,7 @@ export function useSidebarController({ setEditingName(''); } }, - [editingName], + [editingName, paletteOps], ); const showDeleteSessionConfirmation = useCallback( diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 11a7dbe7..97484b01 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -6,6 +6,7 @@ import { useVersionCheck } from '../../../hooks/useVersionCheck'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useSidebarController } from '../hooks/useSidebarController'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; +import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import type { Project, LLMProvider } from '../../../types/app'; import type { MCPServerStatus, SidebarProps } from '../types/types'; @@ -49,6 +50,7 @@ function Sidebar({ const { sidebarVisible } = preferences; const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext; const { tasksEnabled } = useTasksSettings(); + const paletteOps = usePaletteOps(); const { isSidebarCollapsed, @@ -128,12 +130,7 @@ function Sidebar({ }, [isPWA]); const handleProjectCreated = () => { - if (window.refreshProjects) { - void window.refreshProjects(); - return; - } - - window.location.reload(); + void paletteOps.refreshProjects(); }; const projectListProps: SidebarProjectListProps = { diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index 551c0095..ab1eed7e 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -5,6 +5,9 @@ import { IS_PLATFORM } from '../../../../constants/config'; import { cn } from '../../../../lib/utils'; import GitHubStarBadge from './GitHubStarBadge'; +const MOD_KEY = + typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'; + type SearchMode = 'projects' | 'conversations'; type SidebarHeaderProps = { @@ -148,9 +151,9 @@ export default function SidebarHeader({ placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')} value={searchFilter} onChange={(event) => onSearchFilterChange(event.target.value)} - className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" + className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" /> - {searchFilter && ( + {searchFilter ? ( + ) : ( + + {MOD_KEY} + K + )}
diff --git a/src/contexts/PaletteOpsContext.tsx b/src/contexts/PaletteOpsContext.tsx new file mode 100644 index 00000000..c0281780 --- /dev/null +++ b/src/contexts/PaletteOpsContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useEffect, useMemo, useRef } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; + +export type PaletteOps = { + openFile: (path: string) => void; + openSettings: (tab?: string) => void; + refreshProjects: () => Promise | void; +}; + +type Registry = MutableRefObject>; + +const PaletteOpsContext = createContext(null); + +const defaultOps: PaletteOps = { + openFile: () => undefined, + openSettings: () => undefined, + refreshProjects: () => undefined, +}; + +export function PaletteOpsProvider({ children }: { children: ReactNode }) { + const ref = useRef>({}); + return {children}; +} + +export function usePaletteOps(): PaletteOps { + const ref = useContext(PaletteOpsContext); + return useMemo( + () => ({ + openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path), + openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab), + refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(), + }), + [ref], + ); +} + +export function usePaletteOpsRegister(partial: Partial) { + const ref = useContext(PaletteOpsContext); + const { openFile, openSettings, refreshProjects } = partial; + + useEffect(() => { + if (!ref) return undefined; + const prev = { ...ref.current }; + if (openFile) ref.current.openFile = openFile; + if (openSettings) ref.current.openSettings = openSettings; + if (refreshProjects) ref.current.refreshProjects = refreshProjects; + return () => { + if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile; + if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings; + if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects; + }; + }, [ref, openFile, openSettings, refreshProjects]); +} diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index 4d978bcf..b8a2d586 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -188,7 +188,8 @@ "codex": "Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "gemini": "Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "default": "Wähl oben einen Anbieter, um zu beginnen" - } + }, + "pressToSearch": "Drücke {{shortcut}}, um Sitzungen, Dateien und Commits zu durchsuchen" }, "session": { "continue": { diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json index b2df5190..ecc489ea 100644 --- a/src/i18n/locales/de/sidebar.json +++ b/src/i18n/locales/de/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "Diese Sitzung dauerhaft löschen", "save": "Speichern", "cancel": "Abbrechen", - "clearSearch": "Suche leeren" + "clearSearch": "Suche leeren", + "openCommandPalette": "Befehlspalette öffnen" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index dadaea89..32e756a2 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -188,7 +188,8 @@ "codex": "Ready to use Codex with {{model}}. Start typing your message below.", "gemini": "Ready to use Gemini with {{model}}. Start typing your message below.", "default": "Select a provider above to begin" - } + }, + "pressToSearch": "Press {{shortcut}} to search sessions, files, and commits" }, "session": { "continue": { diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index 7b3452cb..43498ce7 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "Delete this session permanently", "save": "Save", "cancel": "Cancel", - "clearSearch": "Clear search" + "clearSearch": "Clear search", + "openCommandPalette": "Open command palette" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 78a7555a..06f18b08 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -188,7 +188,8 @@ "codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "default": "Seleziona un provider sopra per iniziare" - } + }, + "pressToSearch": "Premi {{shortcut}} per cercare sessioni, file e commit" }, "session": { "continue": { diff --git a/src/i18n/locales/it/sidebar.json b/src/i18n/locales/it/sidebar.json index 79a71bad..986e5fd1 100644 --- a/src/i18n/locales/it/sidebar.json +++ b/src/i18n/locales/it/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "Elimina questa sessione permanentemente", "save": "Salva", "cancel": "Annulla", - "clearSearch": "Cancella ricerca" + "clearSearch": "Cancella ricerca", + "openCommandPalette": "Apri tavolozza comandi" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 10e03192..eb878aa6 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -165,7 +165,8 @@ "cursor": "{{model}}でCursorを使用する準備ができました。下にメッセージを入力してください。", "codex": "{{model}}でCodexを使用する準備ができました。下にメッセージを入力してください。", "default": "上からプロバイダーを選択して開始してください" - } + }, + "pressToSearch": "{{shortcut}} を押してセッション、ファイル、コミットを検索" }, "session": { "continue": { diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json index 33c17399..98861878 100644 --- a/src/i18n/locales/ja/sidebar.json +++ b/src/i18n/locales/ja/sidebar.json @@ -46,7 +46,8 @@ "editSessionName": "セッション名を手動で編集", "deleteSession": "このセッションを完全に削除", "save": "保存", - "cancel": "キャンセル" + "cancel": "キャンセル", + "openCommandPalette": "コマンドパレットを開く" }, "navigation": { "chat": "チャット", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index aaf5b45c..bc4775b8 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -170,7 +170,8 @@ "codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "default": "시작하려면 위에서 제공자를 선택하세요" - } + }, + "pressToSearch": "{{shortcut}}를 눌러 세션, 파일 및 커밋을 검색하세요" }, "session": { "continue": { diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index d7fcafaa..cd7e0289 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -46,7 +46,8 @@ "editSessionName": "세션 이름 직접 편집", "deleteSession": "이 세션 영구 삭제", "save": "저장", - "cancel": "취소" + "cancel": "취소", + "openCommandPalette": "명령 팔레트 열기" }, "navigation": { "chat": "채팅", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index a1c5e277..4fd1a0b4 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -188,7 +188,8 @@ "codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.", "gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.", "default": "Выберите провайдера выше для начала" - } + }, + "pressToSearch": "Нажмите {{shortcut}}, чтобы искать сессии, файлы и коммиты" }, "session": { "continue": { diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json index 9af33d54..fee429b1 100644 --- a/src/i18n/locales/ru/sidebar.json +++ b/src/i18n/locales/ru/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "Удалить этот сеанс навсегда", "save": "Сохранить", "cancel": "Отмена", - "clearSearch": "Очистить поиск" + "clearSearch": "Очистить поиск", + "openCommandPalette": "Открыть палитру команд" }, "navigation": { "chat": "Чат", diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index a866fcce..2f60b91c 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -188,7 +188,8 @@ "codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "default": "Başlamak için yukarıdan bir sağlayıcı seç" - } + }, + "pressToSearch": "Oturumlarda, dosyalarda ve commit'lerde arama yapmak için {{shortcut}} tuşlarına bas" }, "session": { "continue": { diff --git a/src/i18n/locales/tr/sidebar.json b/src/i18n/locales/tr/sidebar.json index b0ba4ad7..0640fca5 100644 --- a/src/i18n/locales/tr/sidebar.json +++ b/src/i18n/locales/tr/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "Bu oturumu kalıcı olarak sil", "save": "Kaydet", "cancel": "İptal", - "clearSearch": "Aramayı temizle" + "clearSearch": "Aramayı temizle", + "openCommandPalette": "Komut paletini aç" }, "navigation": { "chat": "Sohbet", diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 1d224cc7..a478f18a 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -170,7 +170,8 @@ "codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。", "gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。", "default": "请在上方选择一个提供者以开始" - } + }, + "pressToSearch": "按 {{shortcut}} 搜索会话、文件和提交" }, "session": { "continue": { diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 85053d92..9de4ea83 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -47,7 +47,8 @@ "deleteSession": "永久删除此会话", "save": "保存", "cancel": "取消", - "clearSearch": "清除搜索" + "clearSearch": "清除搜索", + "openCommandPalette": "打开命令面板" }, "navigation": { "chat": "聊天", diff --git a/src/shared/view/ui/Command.tsx b/src/shared/view/ui/Command.tsx index 5bdccdbb..a66fc17c 100644 --- a/src/shared/view/ui/Command.tsx +++ b/src/shared/view/ui/Command.tsx @@ -1,320 +1,107 @@ import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; import { Search } from 'lucide-react'; import { cn } from '../../../lib/utils'; -/* - * Lightweight command palette — inspired by cmdk but no external deps. - * - * Architecture: - * - Command owns the search string and a flat list of registered item values. - * - Items register via context on mount and deregister on unmount. - * - Filtering, active index, and keyboard nav happen centrally in Command. - * - Items read their "is visible" / "is active" state from context. - */ +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; -interface ItemEntry { - id: string; - value: string; // searchable text (lowercase) - onSelect: () => void; - element: HTMLElement | null; -} - -interface CommandContextValue { - search: string; - setSearch: (value: string) => void; - /** Set of visible item IDs after filtering (derived state, not a ref). */ - visibleIds: Set; - activeId: string | null; - setActiveId: (id: string | null) => void; - register: (entry: ItemEntry) => void; - unregister: (id: string) => void; - updateEntry: (id: string, patch: Partial>) => void; -} - -const CommandContext = React.createContext(null); - -function useCommand() { - const ctx = React.useContext(CommandContext); - if (!ctx) throw new Error('Command components must be used within '); - return ctx; -} - -/* ─── Command (root) ─────────────────────────────────────────────── */ - -type CommandProps = React.HTMLAttributes; - -const Command = React.forwardRef( - ({ className, children, ...props }, ref) => { - const [search, setSearch] = React.useState(''); - const entriesRef = React.useRef>(new Map()); - // Bump this counter whenever the entry set changes so derived state recalculates - const [revision, setRevision] = React.useState(0); - - const register = React.useCallback((entry: ItemEntry) => { - entriesRef.current.set(entry.id, entry); - setRevision(r => r + 1); - }, []); - - const unregister = React.useCallback((id: string) => { - entriesRef.current.delete(id); - setRevision(r => r + 1); - }, []); - - const updateEntry = React.useCallback((id: string, patch: Partial>) => { - const existing = entriesRef.current.get(id); - if (existing) { - Object.assign(existing, patch); - } - }, []); - - // Derive visible IDs from search + entries - const visibleIds = React.useMemo(() => { - const lowerSearch = search.toLowerCase(); - const ids = new Set(); - for (const [id, entry] of entriesRef.current) { - if (!lowerSearch || entry.value.includes(lowerSearch)) { - ids.add(id); - } - } - return ids; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [search, revision]); - - // Ordered list of visible entries (preserves DOM order via insertion order) - const visibleEntries = React.useMemo(() => { - const result: ItemEntry[] = []; - for (const [, entry] of entriesRef.current) { - if (visibleIds.has(entry.id)) result.push(entry); - } - return result; - }, [visibleIds]); - - // Active item tracking - const [activeId, setActiveId] = React.useState(null); - - // Reset active to first visible item when search or visible set changes - React.useEffect(() => { - setActiveId(visibleEntries.length > 0 ? visibleEntries[0].id : null); - }, [visibleEntries]); - - const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { - e.preventDefault(); - } else { - return; - } - - const entries = visibleEntries; - if (entries.length === 0) return; - - if (e.key === 'Enter') { - const active = entries.find(entry => entry.id === activeId); - active?.onSelect(); - return; - } - - const currentIndex = entries.findIndex(entry => entry.id === activeId); - let nextIndex: number; - if (e.key === 'ArrowDown') { - nextIndex = currentIndex < entries.length - 1 ? currentIndex + 1 : 0; - } else { - nextIndex = currentIndex > 0 ? currentIndex - 1 : entries.length - 1; - } - const nextId = entries[nextIndex].id; - setActiveId(nextId); - - // Scroll the active item into view - const nextEntry = entries[nextIndex]; - nextEntry.element?.scrollIntoView({ block: 'nearest' }); - }, [visibleEntries, activeId]); - - const value = React.useMemo( - () => ({ search, setSearch, visibleIds, activeId, setActiveId, register, unregister, updateEntry }), - [search, visibleIds, activeId, register, unregister, updateEntry] - ); - - return ( - -
- {children} -
-
- ); - } -); -Command.displayName = 'Command'; - -/* ─── CommandInput ───────────────────────────────────────────────── */ - -type CommandInputProps = Omit, 'onChange' | 'value' | 'type'>; - -const CommandInput = React.forwardRef( - ({ className, placeholder = 'Search...', ...props }, ref) => { - const { search, setSearch } = useCommand(); - - return ( -
- - setSearch(e.target.value)} - placeholder={placeholder} - className={cn( - 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none', - 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', - className - )} - {...props} - /> -
- ); - } -); -CommandInput.displayName = 'CommandInput'; - -/* ─── CommandList ────────────────────────────────────────────────── */ - -const CommandList = React.forwardRef>( - ({ className, ...props }, ref) => ( -
, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + - ) -); -CommandList.displayName = 'CommandList'; +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; -/* ─── CommandEmpty ───────────────────────────────────────────────── */ +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; -const CommandEmpty = React.forwardRef>( - ({ className, ...props }, ref) => { - const { search, visibleIds } = useCommand(); +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - // Only show when there's a search term and zero matches - if (!search || visibleIds.size > 0) return null; +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; - return ( -
- ); - } -); -CommandEmpty.displayName = 'CommandEmpty'; +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; -/* ─── CommandGroup ───────────────────────────────────────────────── */ - -interface CommandGroupProps extends React.HTMLAttributes { - heading?: React.ReactNode; -} - -const CommandGroup = React.forwardRef( - ({ className, heading, children, ...props }, ref) => ( -
- {heading && ( -
- {heading} -
- )} - {children} -
- ) -); -CommandGroup.displayName = 'CommandGroup'; - -/* ─── CommandItem ────────────────────────────────────────────────── */ - -interface CommandItemProps extends React.HTMLAttributes { - value?: string; - onSelect?: () => void; - disabled?: boolean; -} - -const CommandItem = React.forwardRef( - ({ className, value, onSelect, disabled, children, ...props }, ref) => { - const { visibleIds, activeId, setActiveId, register, unregister, updateEntry } = useCommand(); - const stableId = React.useId(); - const elementRef = React.useRef(null); - const searchableText = value || (typeof children === 'string' ? children : ''); - - // Register on mount, unregister on unmount - React.useEffect(() => { - register({ - id: stableId, - value: searchableText.toLowerCase(), - onSelect: onSelect || (() => {}), - element: elementRef.current, - }); - return () => unregister(stableId); - // Only re-register when the identity changes, not onSelect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stableId, searchableText, register, unregister]); - - // Keep onSelect up-to-date without re-registering - React.useEffect(() => { - updateEntry(stableId, { onSelect: onSelect || (() => {}) }); - }, [stableId, onSelect, updateEntry]); - - // Keep element ref up-to-date - const setRef = React.useCallback((node: HTMLDivElement | null) => { - elementRef.current = node; - updateEntry(stableId, { element: node }); - if (typeof ref === 'function') ref(node); - else if (ref) ref.current = node; - }, [stableId, updateEntry, ref]); - - // Hidden by filter - if (!visibleIds.has(stableId)) return null; - - const isActive = activeId === stableId; - - return ( -
{ if (!disabled && activeId !== stableId) setActiveId(stableId); }} - onClick={() => !disabled && onSelect?.()} - {...props} - > - {children} -
- ); - } -); -CommandItem.displayName = 'CommandItem'; - -/* ─── CommandSeparator ───────────────────────────────────────────── */ - -const CommandSeparator = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ) -); -CommandSeparator.displayName = 'CommandSeparator'; +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator }; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ba368e4b..7ed066a4 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -3,8 +3,6 @@ export {}; declare global { interface Window { __ROUTER_BASENAME__?: string; - refreshProjects?: () => void | Promise; - openSettings?: (tab?: string) => void; } interface EventSourceEventMap {