mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
wip: desktop work
This commit is contained in:
27
bun.lock
27
bun.lock
@@ -115,6 +115,7 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
@@ -273,7 +274,9 @@
|
|||||||
"version": "0.15.13",
|
"version": "0.15.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
|
"fuzzysort": "catalog:",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
@@ -931,6 +934,8 @@
|
|||||||
|
|
||||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
|
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
|
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
|
||||||
@@ -1181,6 +1186,8 @@
|
|||||||
|
|
||||||
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
||||||
|
|
||||||
|
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
|
||||||
|
|
||||||
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
||||||
|
|
||||||
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
||||||
@@ -3503,6 +3510,12 @@
|
|||||||
|
|
||||||
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
||||||
|
|
||||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||||
|
|
||||||
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
@@ -4065,6 +4078,20 @@
|
|||||||
|
|
||||||
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
|
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
|
||||||
|
|
||||||
|
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
|
"@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
@@ -33,14 +35,13 @@
|
|||||||
"@solidjs/router": "0.15.3",
|
"@solidjs/router": "0.15.3",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "16.2.0",
|
"marked": "16.2.0",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"solid-js": "catalog:",
|
|
||||||
"shiki": "3.9.2",
|
"shiki": "3.9.2",
|
||||||
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"virtua": "catalog:"
|
"virtua": "catalog:"
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export function Code(props: Props) {
|
|||||||
[&_.diff-blank_.diff-oldln]:bg-background-element
|
[&_.diff-blank_.diff-oldln]:bg-background-element
|
||||||
[&_.diff-blank_.diff-newln]:bg-background-element
|
[&_.diff-blank_.diff-newln]:bg-background-element
|
||||||
[&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
|
[&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
|
||||||
[&_.diff-collapsed]:cursor-pointer [&_.diff-collapsed]:select-none
|
[&_.diff-collapsed]:select-none
|
||||||
[&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
|
[&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
|
||||||
[&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
|
[&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
|
||||||
[&_.diff-collapsed]:text-xs
|
[&_.diff-collapsed]:text-xs
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
||||||
import { Tabs, Tooltip } from "@opencode-ai/ui"
|
import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
|
||||||
import { Icon } from "@opencode-ai/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import { FileIcon, IconButton } from "@/ui"
|
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -92,20 +91,16 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||||||
<Show when={view !== "raw"}>
|
<Show when={view !== "raw"}>
|
||||||
<div class="mr-1 flex items-center gap-1">
|
<div class="mr-1 flex items-center gap-1">
|
||||||
<Tooltip value="Previous change" placement="bottom">
|
<Tooltip value="Previous change" placement="bottom">
|
||||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
||||||
<Icon name="arrow-up" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip value="Next change" placement="bottom">
|
<Tooltip value="Next change" placement="bottom">
|
||||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
||||||
<Icon name="arrow-down" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Tooltip value="Raw" placement="bottom">
|
<Tooltip value="Raw" placement="bottom">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
icon="file-text"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
classList={{
|
classList={{
|
||||||
"text-text": view === "raw",
|
"text-text": view === "raw",
|
||||||
@@ -113,13 +108,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||||||
"bg-background-element": view === "raw",
|
"bg-background-element": view === "raw",
|
||||||
}}
|
}}
|
||||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||||
>
|
/>
|
||||||
<Icon name="file-text" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip value="Unified diff" placement="bottom">
|
<Tooltip value="Unified diff" placement="bottom">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
icon="checklist"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
classList={{
|
classList={{
|
||||||
"text-text": view === "diff-unified",
|
"text-text": view === "diff-unified",
|
||||||
@@ -127,13 +120,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||||||
"bg-background-element": view === "diff-unified",
|
"bg-background-element": view === "diff-unified",
|
||||||
}}
|
}}
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||||
>
|
/>
|
||||||
<Icon name="checklist" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip value="Split diff" placement="bottom">
|
<Tooltip value="Split diff" placement="bottom">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
icon="columns"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
classList={{
|
classList={{
|
||||||
"text-text": view === "diff-split",
|
"text-text": view === "diff-split",
|
||||||
@@ -141,9 +132,7 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||||||
"bg-background-element": view === "diff-split",
|
"bg-background-element": view === "diff-split",
|
||||||
}}
|
}}
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||||
>
|
/>
|
||||||
<Icon name="columns" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -221,13 +210,11 @@ function SortableTab(props: {
|
|||||||
<TabVisual file={props.file} />
|
<TabVisual file={props.file} />
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
icon="close"
|
||||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => props.onTabClose(props.file)}
|
onClick={() => props.onTabClose(props.file)}
|
||||||
>
|
/>
|
||||||
<Icon name="close" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function FileTree(props: {
|
|||||||
<Dynamic
|
<Dynamic
|
||||||
component={p.as ?? "div"}
|
component={p.as ?? "div"}
|
||||||
classList={{
|
classList={{
|
||||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
|
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||||
}}
|
}}
|
||||||
@@ -83,7 +83,7 @@ export default function FileTree(props: {
|
|||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Node node={node}>
|
<Node node={node}>
|
||||||
<Collapsible.Arrow size={16} class="text-text-muted/60 ml-1" />
|
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
||||||
<FileIcon
|
<FileIcon
|
||||||
node={node}
|
node={node}
|
||||||
expanded={local.file.node(node.path).expanded}
|
expanded={local.file.node(node.path).expanded}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface PromptTextPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptAttachmentPart {
|
export interface PromptAttachmentPart {
|
||||||
kind: "attachment"
|
kind: "file"
|
||||||
token: string
|
token: string
|
||||||
display: string
|
display: string
|
||||||
path: string
|
path: string
|
||||||
@@ -106,7 +106,7 @@ export function parsePrompt(value: string, lookup: Map<string, AttachmentCandida
|
|||||||
const start = rangeStart + localIndex
|
const start = rangeStart + localIndex
|
||||||
const end = start + match[0].length
|
const end = start + match[0].length
|
||||||
segments.push({
|
segments.push({
|
||||||
kind: "attachment",
|
kind: "file",
|
||||||
token,
|
token,
|
||||||
display: candidate.display,
|
display: candidate.display,
|
||||||
path: candidate.path,
|
path: candidate.path,
|
||||||
@@ -152,7 +152,7 @@ export function composeDisplaySegments(
|
|||||||
}
|
}
|
||||||
const { start, end, ...part } = segment
|
const { start, end, ...part } = segment
|
||||||
const placeholder = inputValue.slice(start, end)
|
const placeholder = inputValue.slice(start, end)
|
||||||
return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder }
|
return { kind: "file", part: part as PromptAttachmentPart, source: placeholder }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (interim) {
|
if (interim) {
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export function useMentionController(options: MentionControllerOptions) {
|
|||||||
const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
|
const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
|
||||||
const origin = options.getActiveContext()?.path === path ? "active" : "context"
|
const origin = options.getActiveContext()?.path === path ? "active" : "context"
|
||||||
const part: PromptAttachmentPart = {
|
const part: PromptAttachmentPart = {
|
||||||
kind: "attachment",
|
kind: "file",
|
||||||
token: alias,
|
token: alias,
|
||||||
display: createAttachmentDisplay(path, node?.selection),
|
display: createAttachmentDisplay(path, node?.selection),
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -1,63 +1,74 @@
|
|||||||
import { createEffect, on, Component, createMemo, Show } from "solid-js"
|
import { useLocal } from "@/context"
|
||||||
|
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||||
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { FileIcon } from "@/ui"
|
||||||
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
|
import { TextSelection } from "@/context/local"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
interface TextPart {
|
interface PartBase {
|
||||||
type: "text"
|
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttachmentPart {
|
interface TextPart extends PartBase {
|
||||||
type: "attachment"
|
type: "text"
|
||||||
fileId: string
|
|
||||||
name: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentPart = TextPart | AttachmentPart
|
interface FileAttachmentPart extends PartBase {
|
||||||
|
type: "file"
|
||||||
export interface AttachmentToAdd {
|
path: string
|
||||||
id: string
|
selection?: TextSelection
|
||||||
name: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddAttachmentCallback = (attachment: AttachmentToAdd) => void
|
export type ContentPart = TextPart | FileAttachmentPart
|
||||||
|
|
||||||
export interface PopoverState {
|
|
||||||
isOpen: boolean
|
|
||||||
searchQuery: string
|
|
||||||
addAttachment: AddAttachmentCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
onSubmit: (parts: ContentPart[]) => void
|
onSubmit: (parts: ContentPart[]) => void
|
||||||
onShowAttachments?: (state: PopoverState | null) => void
|
|
||||||
class?: string
|
class?: string
|
||||||
|
ref?: (el: HTMLDivElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
let editorRef: HTMLDivElement | undefined
|
const local = useLocal()
|
||||||
|
let editorRef!: HTMLDivElement
|
||||||
|
|
||||||
const defaultParts = [{ type: "text", content: "" } as const]
|
const defaultParts = [{ type: "text", content: "" } as const]
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
contentParts: ContentPart[]
|
contentParts: ContentPart[]
|
||||||
popover: {
|
popoverIsOpen: boolean
|
||||||
isOpen: boolean
|
|
||||||
searchQuery: string
|
|
||||||
}
|
|
||||||
}>({
|
}>({
|
||||||
contentParts: defaultParts,
|
contentParts: defaultParts,
|
||||||
popover: {
|
popoverIsOpen: false,
|
||||||
isOpen: false,
|
|
||||||
searchQuery: "",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
||||||
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isFocused()) {
|
||||||
|
handleInput()
|
||||||
|
} else {
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
||||||
|
items: local.file.search,
|
||||||
|
key: (x) => x,
|
||||||
|
onSelect: (path) => {
|
||||||
|
if (!path) return
|
||||||
|
addPart({ type: "file", path, content: "@" + getFilename(path) })
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => store.contentParts,
|
() => store.contentParts,
|
||||||
(currentParts) => {
|
(currentParts) => {
|
||||||
if (!editorRef) return
|
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isEqual(currentParts, domParts)) return
|
if (isEqual(currentParts, domParts)) return
|
||||||
|
|
||||||
@@ -70,14 +81,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef.innerHTML = ""
|
editorRef.innerHTML = ""
|
||||||
currentParts.forEach((part) => {
|
currentParts.forEach((part) => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
editorRef!.appendChild(document.createTextNode(part.content))
|
editorRef.appendChild(document.createTextNode(part.content))
|
||||||
} else if (part.type === "attachment") {
|
} else if (part.type === "file") {
|
||||||
const pill = document.createElement("span")
|
const pill = document.createElement("span")
|
||||||
pill.textContent = `@${part.name}`
|
pill.textContent = part.content
|
||||||
pill.className = "attachment-pill"
|
pill.setAttribute("data-type", "file")
|
||||||
pill.setAttribute("data-file-id", part.fileId)
|
pill.setAttribute("data-path", part.path)
|
||||||
pill.setAttribute("contenteditable", "false")
|
pill.setAttribute("contenteditable", "false")
|
||||||
editorRef!.appendChild(pill)
|
pill.style.userSelect = "text"
|
||||||
|
pill.style.cursor = "default"
|
||||||
|
editorRef.appendChild(pill)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,30 +101,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (store.popover.isOpen) {
|
|
||||||
props.onShowAttachments?.({
|
|
||||||
isOpen: true,
|
|
||||||
searchQuery: store.popover.searchQuery,
|
|
||||||
addAttachment: addAttachment,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
props.onShowAttachments?.(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const parseFromDOM = (): ContentPart[] => {
|
const parseFromDOM = (): ContentPart[] => {
|
||||||
if (!editorRef) return []
|
|
||||||
const newParts: ContentPart[] = []
|
const newParts: ContentPart[] = []
|
||||||
editorRef.childNodes.forEach((node) => {
|
editorRef.childNodes.forEach((node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) {
|
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
||||||
newParts.push({
|
switch ((node as HTMLElement).dataset.type) {
|
||||||
type: "attachment",
|
case "file":
|
||||||
fileId: (node as HTMLElement).dataset.fileId!,
|
newParts.push({
|
||||||
name: node.textContent!.substring(1),
|
type: "file",
|
||||||
})
|
path: (node as HTMLElement).dataset.path!,
|
||||||
|
content: node.textContent!,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (newParts.length === 0) newParts.push(...defaultParts)
|
if (newParts.length === 0) newParts.push(...defaultParts)
|
||||||
@@ -120,96 +126,234 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleInput = () => {
|
const handleInput = () => {
|
||||||
const rawParts = parseFromDOM()
|
const rawParts = parseFromDOM()
|
||||||
const cursorPosition = getCursorPosition(editorRef!)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
const rawText = rawParts.map((p) => p.content).join("")
|
||||||
|
|
||||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
setStore("popover", { isOpen: true, searchQuery: atMatch[1] })
|
onInput(atMatch[1])
|
||||||
} else if (store.popover.isOpen) {
|
setStore("popoverIsOpen", true)
|
||||||
setStore("popover", "isOpen", false)
|
} else if (store.popoverIsOpen) {
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("contentParts", rawParts)
|
setStore("contentParts", rawParts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAttachment: AddAttachmentCallback = (attachment) => {
|
const addPart = (part: ContentPart) => {
|
||||||
const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const cursorPosition = getCursorPosition(editorRef!)
|
const rawText = store.contentParts.map((p) => p.content).join("")
|
||||||
|
|
||||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||||
|
|
||||||
if (!atMatch) return
|
if (!atMatch) return
|
||||||
|
|
||||||
const startIndex = atMatch.index!
|
const startIndex = atMatch.index!
|
||||||
|
const endIndex = cursorPosition
|
||||||
|
|
||||||
// Create new structured content
|
const {
|
||||||
const newParts: ContentPart[] = []
|
parts: nextParts,
|
||||||
const textBeforeTrigger = rawText.substring(0, startIndex)
|
cursorIndex,
|
||||||
if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
|
cursorOffset,
|
||||||
|
inserted,
|
||||||
|
} = store.contentParts.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (acc.inserted) {
|
||||||
|
acc.parts.push(item)
|
||||||
|
acc.runningIndex += item.content.length
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
|
const nextIndex = acc.runningIndex + item.content.length
|
||||||
|
if (nextIndex <= startIndex) {
|
||||||
|
acc.parts.push(item)
|
||||||
|
acc.runningIndex = nextIndex
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
// Add a space after the pill for better UX
|
if (item.type !== "text") {
|
||||||
newParts.push({ type: "text", content: " " })
|
acc.parts.push(item)
|
||||||
|
acc.runningIndex = nextIndex
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
const textAfterCursor = rawText.substring(cursorPosition)
|
const headLength = Math.max(0, startIndex - acc.runningIndex)
|
||||||
if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
|
const tailLength = Math.max(0, endIndex - acc.runningIndex)
|
||||||
|
const head = item.content.slice(0, headLength)
|
||||||
|
const tail = item.content.slice(tailLength)
|
||||||
|
|
||||||
setStore("contentParts", newParts)
|
if (head) acc.parts.push({ type: "text", content: head })
|
||||||
setStore("popover", "isOpen", false)
|
|
||||||
|
acc.parts.push(part)
|
||||||
|
|
||||||
|
const rest = /^\s/.test(tail) ? tail : ` ${tail}`
|
||||||
|
if (rest) {
|
||||||
|
acc.cursorIndex = acc.parts.length
|
||||||
|
acc.cursorOffset = Math.min(1, rest.length)
|
||||||
|
acc.parts.push({ type: "text", content: rest })
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.inserted = true
|
||||||
|
acc.runningIndex = nextIndex
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parts: [] as ContentPart[],
|
||||||
|
runningIndex: 0,
|
||||||
|
inserted: false,
|
||||||
|
cursorIndex: null as number | null,
|
||||||
|
cursorOffset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!inserted || cursorIndex === null) return
|
||||||
|
|
||||||
|
setStore("contentParts", nextParts)
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
|
|
||||||
// Set cursor position after the newly added pill + space
|
|
||||||
// We need to wait for the DOM to update
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1)
|
const node = editorRef.childNodes[cursorIndex]
|
||||||
|
if (node && node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const length = node.textContent ? node.textContent.length : 0
|
||||||
|
const offset = cursorOffset > length ? length : cursorOffset
|
||||||
|
range.setStart(node, offset)
|
||||||
|
range.collapse(true)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||||
// In a real implementation, you'd prevent default and delegate this to the popover
|
onKeyDown(event)
|
||||||
console.log("Key press delegated to popover:", event.key)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault()
|
handleSubmit(event)
|
||||||
if (store.contentParts.length > 0) {
|
}
|
||||||
props.onSubmit([...store.contentParts])
|
}
|
||||||
setStore("contentParts", defaultParts)
|
|
||||||
}
|
const handleSubmit = (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (store.contentParts.length > 0) {
|
||||||
|
props.onSubmit([...store.contentParts])
|
||||||
|
setStore("contentParts", defaultParts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
|
||||||
classList={{
|
<Show when={store.popoverIsOpen}>
|
||||||
"size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
||||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
<For each={flat()}>
|
||||||
[props.class ?? ""]: !!props.class,
|
{(i) => (
|
||||||
}}
|
<div
|
||||||
>
|
classList={{
|
||||||
<div class="p-3" />
|
"w-full flex items-center justify-between rounded-md": true,
|
||||||
<div class="relative">
|
"bg-surface-raised-base-hover": active() === i,
|
||||||
<div
|
}}
|
||||||
ref={editorRef}
|
>
|
||||||
contenteditable="true"
|
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||||
onInput={handleInput}
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
onKeyDown={handleKeyDown}
|
<div class="flex items-center text-14-regular">
|
||||||
classList={{
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
"w-full p-3 text-sm focus:outline-none": true,
|
{getDirectory(i)}/
|
||||||
}}
|
</span>
|
||||||
/>
|
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||||
<Show when={isEmpty()}>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none">
|
</div>
|
||||||
Plan and build anything
|
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
classList={{
|
||||||
|
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||||
|
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
||||||
|
[props.class ?? ""]: !!props.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="relative max-h-[240px] overflow-y-auto">
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
editorRef = el
|
||||||
|
props.ref?.(el)
|
||||||
|
}}
|
||||||
|
contenteditable="true"
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
classList={{
|
||||||
|
"w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
|
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={isEmpty()}>
|
||||||
|
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
||||||
|
Plan and build anything
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center justify-start gap-1">
|
||||||
|
<Select
|
||||||
|
options={local.agent.list().map((agent) => agent.name)}
|
||||||
|
current={local.agent.current().name}
|
||||||
|
onSelect={local.agent.set}
|
||||||
|
class="capitalize"
|
||||||
|
/>
|
||||||
|
<SelectDialog
|
||||||
|
title="Select model"
|
||||||
|
placeholder="Search models"
|
||||||
|
emptyMessage="No model results"
|
||||||
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
|
items={local.model.list()}
|
||||||
|
current={local.model.current()}
|
||||||
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
|
groupBy={(x) => x.provider.name}
|
||||||
|
sortGroupsBy={(a, b) => {
|
||||||
|
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||||
|
const aProvider = a.items[0].provider.id
|
||||||
|
const bProvider = b.items[0].provider.id
|
||||||
|
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
|
||||||
|
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
|
||||||
|
return order.indexOf(aProvider) - order.indexOf(bProvider)
|
||||||
|
}}
|
||||||
|
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||||
|
trigger={
|
||||||
|
<Button as="div" variant="ghost">
|
||||||
|
{local.model.current()?.name ?? "Select model"}
|
||||||
|
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
|
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
|
||||||
|
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
|
||||||
|
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
||||||
|
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
||||||
|
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
|
||||||
|
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={!i.cost || i.cost?.input === 0}>
|
||||||
|
<div class="overflow-hidden text-12-medium text-text-strong">Free</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectDialog>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3" />
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
|
|||||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
|
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCursorPosition(parent: HTMLElement, position: number) {
|
function setCursorPosition(parent: HTMLElement, position: number) {
|
||||||
let child = parent.firstChild
|
let remaining = position
|
||||||
let offset = position
|
let node = parent.firstChild
|
||||||
while (child) {
|
while (node) {
|
||||||
if (offset > child.textContent!.length) {
|
const length = node.textContent ? node.textContent.length : 0
|
||||||
offset -= child.textContent!.length
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
child = child.nextSibling
|
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
||||||
} else {
|
|
||||||
try {
|
if (isText && remaining <= length) {
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
const sel = window.getSelection()
|
const selection = window.getSelection()
|
||||||
range.setStart(child, offset)
|
range.setStart(node, remaining)
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
sel?.removeAllRanges()
|
selection?.removeAllRanges()
|
||||||
sel?.addRange(range)
|
selection?.addRange(range)
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to set cursor position.", e)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFile && remaining <= length) {
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
range.setStartAfter(node)
|
||||||
|
range.collapse(true)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= length
|
||||||
|
node = node.nextSibling
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackRange = document.createRange()
|
||||||
|
const fallbackSelection = window.getSelection()
|
||||||
|
const last = parent.lastChild
|
||||||
|
if (last && last.nodeType === Node.TEXT_NODE) {
|
||||||
|
const len = last.textContent ? last.textContent.length : 0
|
||||||
|
fallbackRange.setStart(last, len)
|
||||||
|
}
|
||||||
|
if (!last || last.nodeType !== Node.TEXT_NODE) {
|
||||||
|
fallbackRange.selectNodeContents(parent)
|
||||||
|
}
|
||||||
|
fallbackRange.collapse(false)
|
||||||
|
fallbackSelection?.removeAllRanges()
|
||||||
|
fallbackSelection?.addRange(fallbackRange)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
|
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
|
||||||
import { Icon } from "@opencode-ai/ui"
|
|
||||||
import { IconButton } from "@/ui"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
|
||||||
import { createList } from "solid-list"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
|
|
||||||
interface SelectDialogProps<T> {
|
|
||||||
items: T[] | ((filter: string) => Promise<T[]>)
|
|
||||||
key: (item: T) => string
|
|
||||||
render: (item: T) => JSX.Element
|
|
||||||
filter?: string[]
|
|
||||||
current?: T
|
|
||||||
placeholder?: string
|
|
||||||
groupBy?: (x: T) => string
|
|
||||||
onSelect?: (value: T | undefined) => void
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
|
||||||
let scrollRef: HTMLDivElement | undefined
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
filter: "",
|
|
||||||
mouseActive: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [grouped] = createResource(
|
|
||||||
() => store.filter,
|
|
||||||
async (filter) => {
|
|
||||||
const needle = filter.toLowerCase()
|
|
||||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
|
||||||
const result = pipe(
|
|
||||||
all,
|
|
||||||
(x) => {
|
|
||||||
if (!needle) return x
|
|
||||||
if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
|
||||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
|
||||||
}
|
|
||||||
return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
|
|
||||||
},
|
|
||||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
|
||||||
// mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
|
|
||||||
entries(),
|
|
||||||
map(([k, v]) => ({ category: k, items: v })),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const flat = createMemo(() => {
|
|
||||||
return pipe(
|
|
||||||
grouped() || [],
|
|
||||||
flatMap((x) => x.items),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const list = createList({
|
|
||||||
items: () => flat().map(props.key),
|
|
||||||
initialActive: props.current ? props.key(props.current) : undefined,
|
|
||||||
loop: true,
|
|
||||||
})
|
|
||||||
const resetSelection = () => {
|
|
||||||
const all = flat()
|
|
||||||
if (all.length === 0) return
|
|
||||||
list.setActive(props.key(all[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
store.filter
|
|
||||||
scrollRef?.scrollTo(0, 0)
|
|
||||||
resetSelection()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const all = flat()
|
|
||||||
if (store.mouseActive || all.length === 0) return
|
|
||||||
if (list.active() === props.key(all[0])) {
|
|
||||||
scrollRef?.scrollTo(0, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
|
|
||||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleInput = (value: string) => {
|
|
||||||
setStore("filter", value)
|
|
||||||
resetSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelect = (item: T) => {
|
|
||||||
props.onSelect?.(item)
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
setStore("mouseActive", false)
|
|
||||||
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
const selected = flat().find((x) => props.key(x) === list.active())
|
|
||||||
if (selected) handleSelect(selected)
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
props.onClose?.()
|
|
||||||
} else {
|
|
||||||
list.onKeyDown(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
|
|
||||||
<Dialog.Content
|
|
||||||
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
|
|
||||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
|
||||||
bg-background border border-border-subtle/30 rounded-lg z-[101]
|
|
||||||
max-h-[60vh] flex flex-col"
|
|
||||||
>
|
|
||||||
<div class="border-b border-border-subtle/30">
|
|
||||||
<div class="relative">
|
|
||||||
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={store.filter}
|
|
||||||
onInput={(e) => handleInput(e.currentTarget.value)}
|
|
||||||
onKeyDown={handleKey}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
class="w-full pl-10 pr-4 py-2 rounded-t-md
|
|
||||||
text-sm text-text placeholder-text-muted/70
|
|
||||||
focus:outline-none"
|
|
||||||
autofocus
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
/>
|
|
||||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
|
||||||
{/* <Show when={fileResults.loading && mode() === "files"}>
|
|
||||||
<div class="text-text-muted">
|
|
||||||
<Icon name="refresh" size={14} class="animate-spin" />
|
|
||||||
</div>
|
|
||||||
</Show> */}
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-text-muted hover:text-text"
|
|
||||||
onClick={() => {
|
|
||||||
setStore("filter", "")
|
|
||||||
resetSelection()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="close" size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
|
|
||||||
<Show
|
|
||||||
when={flat().length > 0}
|
|
||||||
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
|
|
||||||
>
|
|
||||||
<For each={grouped()}>
|
|
||||||
{(group) => (
|
|
||||||
<>
|
|
||||||
<Show when={group.category}>
|
|
||||||
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
|
|
||||||
{group.category}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="p-2">
|
|
||||||
<For each={group.items}>
|
|
||||||
{(item) => (
|
|
||||||
<button
|
|
||||||
data-key={props.key(item)}
|
|
||||||
onClick={() => handleSelect(item)}
|
|
||||||
onMouseMove={() => {
|
|
||||||
setStore("mouseActive", true)
|
|
||||||
list.setActive(props.key(item))
|
|
||||||
}}
|
|
||||||
classList={{
|
|
||||||
"w-full px-3 py-2 flex items-center gap-3": true,
|
|
||||||
"rounded-md text-left transition-colors group": true,
|
|
||||||
"bg-background-element": props.key(item) === list.active(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.render(item)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
|
||||||
↑↓
|
|
||||||
</kbd>
|
|
||||||
Navigate
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
|
||||||
↵
|
|
||||||
</kbd>
|
|
||||||
Select
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
|
||||||
ESC
|
|
||||||
</kbd>
|
|
||||||
Close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>{`${flat().length} results`}</span>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useLocal, useSync } from "@/context"
|
import { useLocal, useSync } from "@/context"
|
||||||
import { Icon, Tooltip } from "@opencode-ai/ui"
|
import { Icon, Tooltip } from "@opencode-ai/ui"
|
||||||
import { Collapsible } from "@/ui"
|
import { Collapsible } from "@/ui"
|
||||||
import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
|
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import {
|
import {
|
||||||
createSignal,
|
createSignal,
|
||||||
onMount,
|
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
splitProps,
|
splitProps,
|
||||||
@@ -67,7 +66,7 @@ function ReadToolPart(props: { part: ToolPart }) {
|
|||||||
{(state) => {
|
{(state) => {
|
||||||
const path = state().input["filePath"] as string
|
const path = state().input["filePath"] as string
|
||||||
return (
|
return (
|
||||||
<Part class="cursor-pointer" onClick={() => local.file.open(path)}>
|
<Part onClick={() => local.file.open(path)}>
|
||||||
<span class="">Read</span> {getFilename(path)}
|
<span class="">Read</span> {getFilename(path)}
|
||||||
</Part>
|
</Part>
|
||||||
)
|
)
|
||||||
@@ -253,7 +252,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
case "patch":
|
case "patch":
|
||||||
return false
|
return false
|
||||||
case "text":
|
case "text":
|
||||||
return !part.synthetic
|
return !part.synthetic && part.text.trim()
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return part.text.trim()
|
return part.text.trim()
|
||||||
case "tool":
|
case "tool":
|
||||||
@@ -270,8 +269,17 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasValidParts = (message: Message) => {
|
||||||
|
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTextPart = (message: Message) => {
|
||||||
|
return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||||
|
}
|
||||||
|
|
||||||
const session = createMemo(() => sync.session.get(props.session))
|
const session = createMemo(() => sync.session.get(props.session))
|
||||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||||
|
const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
|
||||||
const working = createMemo(() => {
|
const working = createMemo(() => {
|
||||||
const last = messages()[messages().length - 1]
|
const last = messages()[messages().length - 1]
|
||||||
if (!last) return false
|
if (!last) return false
|
||||||
@@ -386,7 +394,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="py-1.5 px-10 flex justify-end items-center self-stretch">
|
<div class="py-1.5 px-6 flex justify-end items-center self-stretch">
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
||||||
<Show when={context()}>
|
<Show when={context()}>
|
||||||
@@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6">
|
<ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
|
||||||
<For each={messages()}>
|
<For each={messagesWithValidParts()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<div class="flex flex-col gap-1 justify-center items-start self-stretch">
|
<div
|
||||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
classList={{
|
||||||
|
"flex flex-col gap-1 justify-center items-start self-stretch": true,
|
||||||
|
"mt-6": hasTextPart(message),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={sync.data.part[message.id]?.filter(valid) ?? []}>
|
||||||
{(part) => (
|
{(part) => (
|
||||||
<li class="group/li">
|
<li class="group/li">
|
||||||
<Switch fallback={<div class="">{part.type}</div>}>
|
<Switch fallback={<div class="">{part.type}</div>}>
|
||||||
@@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<Collapsible defaultOpen={false}>
|
<Collapsible defaultOpen={false}>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||||
<Icon name="file-code" size={16} />
|
<Icon name="file-code" />
|
||||||
<span>Raw Session Data</span>
|
<span>Raw Session Data</span>
|
||||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
<Collapsible.Arrow class="text-text-muted" />
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="mt-5">
|
<Collapsible.Content class="mt-5">
|
||||||
@@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<Collapsible>
|
<Collapsible>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
<Icon name="file-code" size={16} />
|
<Icon name="file-code" />
|
||||||
<span>session</span>
|
<span>session</span>
|
||||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
<Collapsible.Arrow class="text-text-muted" />
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
@@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<Collapsible>
|
<Collapsible>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
<Icon name="file-code" size={16} />
|
<Icon name="file-code" />
|
||||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
<Collapsible.Arrow class="text-text-muted" />
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
@@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<Collapsible>
|
<Collapsible>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
<Icon name="file-code" size={16} />
|
<Icon name="file-code" />
|
||||||
<span>{part.type}</span>
|
<span>{part.type}</span>
|
||||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
<Collapsible.Arrow class="text-text-muted" />
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Button, Icon, List, Tooltip } from "@opencode-ai/ui"
|
import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||||
import { FileIcon, IconButton } from "@/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
import EditorPane from "@/components/editor-pane"
|
import EditorPane from "@/components/editor-pane"
|
||||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
import { For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { SelectDialog } from "@/components/select-dialog"
|
|
||||||
import { useSync, useSDK, useLocal } from "@/context"
|
import { useSync, useSDK, useLocal } from "@/context"
|
||||||
import type { LocalFile, TextSelection } from "@/context/local"
|
import type { LocalFile, TextSelection } from "@/context/local"
|
||||||
import SessionTimeline from "@/components/session-timeline"
|
import SessionTimeline from "@/components/session-timeline"
|
||||||
import { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@@ -22,8 +20,7 @@ export default function Page() {
|
|||||||
modelSelectOpen: false,
|
modelSelectOpen: false,
|
||||||
fileSelectOpen: false,
|
fileSelectOpen: false,
|
||||||
})
|
})
|
||||||
|
let inputRef!: HTMLDivElement
|
||||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
|
||||||
|
|
||||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
|
|
||||||
@@ -50,7 +47,7 @@ export default function Page() {
|
|||||||
const focused = document.activeElement === inputRef
|
const focused = document.activeElement === inputRef
|
||||||
if (focused) {
|
if (focused) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
// inputRef?.blur()
|
inputRef?.blur()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -77,7 +74,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||||
// inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +101,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePromptSubmit2 = () => {}
|
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||||
|
|
||||||
const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
|
|
||||||
const existingSession = local.session.active()
|
const existingSession = local.session.active()
|
||||||
let session = existingSession
|
let session = existingSession
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -134,6 +129,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||||
|
|
||||||
|
const text = parts.map((part) => part.content).join("")
|
||||||
const attachments = new Map<string, SubmissionAttachment>()
|
const attachments = new Map<string, SubmissionAttachment>()
|
||||||
|
|
||||||
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
|
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
|
||||||
@@ -147,30 +143,27 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptAttachments = prompt.parts.filter(
|
const promptAttachments = parts.filter((part) => part.type === "file")
|
||||||
(part): part is Extract<PromptContentPart, { kind: "attachment" }> => part.kind === "attachment",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const part of promptAttachments) {
|
for (const part of promptAttachments) {
|
||||||
registerAttachment(part.path, part.selection, part.display)
|
registerAttachment(part.path, part.selection, part.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFile = local.context.active()
|
// const activeFile = local.context.active()
|
||||||
if (activeFile) {
|
// if (activeFile) {
|
||||||
registerAttachment(
|
// registerAttachment(
|
||||||
activeFile.path,
|
// activeFile.path,
|
||||||
activeFile.selection,
|
// activeFile.selection,
|
||||||
activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
|
||||||
for (const contextFile of local.context.all()) {
|
// for (const contextFile of local.context.all()) {
|
||||||
registerAttachment(
|
// registerAttachment(
|
||||||
contextFile.path,
|
// contextFile.path,
|
||||||
contextFile.selection,
|
// contextFile.selection,
|
||||||
formatAttachmentLabel(contextFile.path, contextFile.selection),
|
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
|
||||||
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
|
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
|
||||||
const absolute = toAbsolutePath(attachment.path)
|
const absolute = toAbsolutePath(attachment.path)
|
||||||
@@ -205,7 +198,7 @@ export default function Page() {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: prompt.text,
|
text,
|
||||||
},
|
},
|
||||||
...attachmentParts,
|
...attachmentParts,
|
||||||
],
|
],
|
||||||
@@ -213,16 +206,10 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plus = (
|
const handleNewSession = () => {
|
||||||
<IconButton
|
local.session.setActive(undefined)
|
||||||
class="text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
inputRef?.focus()
|
||||||
size="xs"
|
}
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setStore("fileSelectOpen", true)}
|
|
||||||
>
|
|
||||||
<Icon name="plus" size={12} />
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative h-screen flex flex-col">
|
<div class="relative h-screen flex flex-col">
|
||||||
@@ -234,7 +221,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-4 self-stretch flex-1">
|
<div class="flex flex-col items-start gap-4 self-stretch flex-1">
|
||||||
<div class="px-3 py-1.5 w-full">
|
<div class="px-3 py-1.5 w-full">
|
||||||
<Button class="w-full" size="large">
|
<Button class="w-full" size="large" onClick={handleNewSession}>
|
||||||
|
<Icon name="plus" />
|
||||||
New Session
|
New Session
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,25 +256,30 @@ export default function Page() {
|
|||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative grid grid-cols-2 bg-background-base">
|
<div class="relative grid grid-cols-2 bg-background-base w-full">
|
||||||
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
|
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
|
||||||
<Show when={local.session.active()}>
|
<Show when={local.session.active()}>
|
||||||
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
|
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
|
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
|
||||||
<EditorPane onFileClick={handleFileClick} />
|
<Show when={local.session.active()}>
|
||||||
|
<EditorPane onFileClick={handleFileClick} />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-4 inset-x-0 p-2 flex flex-col justify-center items-center z-50">
|
<div
|
||||||
<PromptInput onSubmit={handlePromptSubmit2} />
|
classList={{
|
||||||
{/* <PromptForm */}
|
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
|
||||||
{/* class="w-2xl" */}
|
"bottom-8": !!local.session.active(),
|
||||||
{/* onSubmit={handlePromptSubmit} */}
|
"bottom-1/2 translate-y-1/2": !local.session.active(),
|
||||||
{/* onOpenModelSelect={() => setStore("modelSelectOpen", true)} */}
|
}}
|
||||||
{/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */}
|
>
|
||||||
{/* inputRef = element ?? undefined */}
|
<PromptInput
|
||||||
{/* }} */}
|
ref={(el) => {
|
||||||
{/* /> */}
|
inputRef = el
|
||||||
|
}}
|
||||||
|
onSubmit={handlePromptSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
||||||
<FileTree path="" onFileClick={handleFileClick} />
|
<FileTree path="" onFileClick={handleFileClick} />
|
||||||
@@ -302,7 +295,7 @@ export default function Page() {
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
||||||
>
|
>
|
||||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||||
@@ -318,59 +311,16 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Show when={store.modelSelectOpen}>
|
|
||||||
<SelectDialog
|
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
|
||||||
items={local.model.list()}
|
|
||||||
current={local.model.current()}
|
|
||||||
render={(i) => (
|
|
||||||
<div class="w-full flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
|
||||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
|
|
||||||
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
|
|
||||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
|
||||||
{i.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
|
|
||||||
<Tooltip forceMount={false} value="Reasoning">
|
|
||||||
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip forceMount={false} value="Tools">
|
|
||||||
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip forceMount={false} value="Attachments">
|
|
||||||
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
|
|
||||||
</Tooltip>
|
|
||||||
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
|
||||||
{new Intl.NumberFormat("en-US", {
|
|
||||||
notation: "compact",
|
|
||||||
compactDisplay: "short",
|
|
||||||
}).format(i.limit.context)}
|
|
||||||
</div>
|
|
||||||
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
|
|
||||||
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
|
||||||
<Switch fallback="FREE">
|
|
||||||
<Match when={i.cost?.input > 10}>$$$</Match>
|
|
||||||
<Match when={i.cost?.input > 1}>$$</Match>
|
|
||||||
<Match when={i.cost?.input > 0.1}>$</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
filter={["provider.name", "name", "id"]}
|
|
||||||
groupBy={(x) => x.provider.name}
|
|
||||||
onClose={() => setStore("modelSelectOpen", false)}
|
|
||||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.fileSelectOpen}>
|
<Show when={store.fileSelectOpen}>
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
|
defaultOpen
|
||||||
|
title="Select file"
|
||||||
items={local.file.search}
|
items={local.file.search}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
render={(i) => (
|
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||||
|
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between">
|
<div class="w-full flex items-center justify-between">
|
||||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
@@ -382,9 +332,7 @@ export default function Page() {
|
|||||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
onClose={() => setStore("fileSelectOpen", false)}
|
</SelectDialog>
|
||||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) {
|
|||||||
return (
|
return (
|
||||||
<KobalteCollapsible.Trigger
|
<KobalteCollapsible.Trigger
|
||||||
classList={{
|
classList={{
|
||||||
"w-full group/collapsible cursor-pointer": true,
|
"w-full group/collapsible": true,
|
||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
}}
|
}}
|
||||||
{...others}
|
{...others}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Button as KobalteButton } from "@kobalte/core/button"
|
|
||||||
import { splitProps } from "solid-js"
|
|
||||||
import type { ComponentProps, JSX } from "solid-js"
|
|
||||||
|
|
||||||
export interface IconButtonProps extends ComponentProps<typeof KobalteButton> {
|
|
||||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
|
||||||
size?: "xs" | "sm" | "md" | "lg"
|
|
||||||
children: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IconButton(props: IconButtonProps) {
|
|
||||||
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
|
|
||||||
return (
|
|
||||||
<KobalteButton
|
|
||||||
classList={{
|
|
||||||
...(local.classList || {}),
|
|
||||||
"inline-flex items-center justify-center rounded-md font-medium cursor-pointer": true,
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2": true,
|
|
||||||
"disabled:pointer-events-none disabled:opacity-50": true,
|
|
||||||
"bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50":
|
|
||||||
(local.variant || "primary") === "primary",
|
|
||||||
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50":
|
|
||||||
local.variant === "secondary",
|
|
||||||
"border border-border bg-transparent text-text hover:bg-background-panel": local.variant === "outline",
|
|
||||||
"focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted":
|
|
||||||
local.variant === "outline",
|
|
||||||
"text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted":
|
|
||||||
local.variant === "ghost",
|
|
||||||
"h-5 w-5 text-xs": local.size === "xs",
|
|
||||||
"h-8 w-8 text-sm": local.size === "sm",
|
|
||||||
"h-10 w-10 text-sm": (local.size || "md") === "md",
|
|
||||||
"h-12 w-12 text-base": local.size === "lg",
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
}}
|
|
||||||
{...others}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,4 +5,3 @@ export {
|
|||||||
type CollapsibleContentProps,
|
type CollapsibleContentProps,
|
||||||
} from "./collapsible"
|
} from "./collapsible"
|
||||||
export { FileIcon, type FileIconProps } from "./file-icon"
|
export { FileIcon, type FileIconProps } from "./file-icon"
|
||||||
export { IconButton, type IconButtonProps } from "./icon-button"
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/components/index.ts",
|
".": "./src/components/index.ts",
|
||||||
"./*": "./src/components/*.tsx",
|
"./*": "./src/components/*.tsx",
|
||||||
|
"./hooks": "./src/hooks/index.ts",
|
||||||
"./styles": "./src/styles/index.css",
|
"./styles": "./src/styles/index.css",
|
||||||
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
||||||
"./fonts/*": "./src/assets/fonts/*"
|
"./fonts/*": "./src/assets/fonts/*"
|
||||||
@@ -23,11 +24,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"remeda": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"virtua": "catalog:",
|
"remeda": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:"
|
"solid-list": "catalog:",
|
||||||
|
"virtua": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
[data-component="button"] {
|
[data-component="button"] {
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -32,12 +31,7 @@
|
|||||||
border-color: var(--border-weak-base);
|
border-color: var(--border-weak-base);
|
||||||
background-color: var(--button-secondary-base);
|
background-color: var(--button-secondary-base);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
/* shadow-xs */
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
|
||||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
|
||||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
border-color: var(--border-hover);
|
border-color: var(--border-hover);
|
||||||
@@ -84,12 +78,11 @@
|
|||||||
padding: 0 8px 0 6px;
|
padding: 0 8px 0 6px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
/* text-12-medium */
|
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-base);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large); /* 166.667% */
|
line-height: var(--line-height-large); /* 171.429% */
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Button as Kobalte } from "@kobalte/core/button"
|
import { Button as Kobalte } from "@kobalte/core/button"
|
||||||
import { type ComponentProps, splitProps } from "solid-js"
|
import { type ComponentProps, splitProps } from "solid-js"
|
||||||
|
|
||||||
export interface ButtonProps {
|
export interface ButtonProps
|
||||||
|
extends ComponentProps<typeof Kobalte>,
|
||||||
|
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||||
size?: "normal" | "large"
|
size?: "normal" | "large"
|
||||||
variant?: "primary" | "secondary" | "ghost"
|
variant?: "primary" | "secondary" | "ghost"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button(props: ComponentProps<"button"> & ButtonProps) {
|
export function Button(props: ButtonProps) {
|
||||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||||
return (
|
return (
|
||||||
<Kobalte
|
<Kobalte
|
||||||
|
|||||||
129
packages/ui/src/components/dialog.css
Normal file
129
packages/ui/src/components/dialog.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* [data-component="dialog-trigger"] { } */
|
||||||
|
|
||||||
|
[data-component="dialog-overlay"] {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
/* animation: overlayHide 250ms ease 100ms forwards; */
|
||||||
|
/**/
|
||||||
|
/* &[data-expanded] { */
|
||||||
|
/* animation: overlayShow 250ms ease; */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog"] {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
[data-slot="container"] {
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
width: min(calc(100vw - 16px), 624px);
|
||||||
|
height: min(calc(100vh - 16px), 512px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: start;
|
||||||
|
|
||||||
|
[data-slot="content"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
/* padding: 8px; */
|
||||||
|
padding: 8px 8px 0 8px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface-raised-stronger-non-alpha);
|
||||||
|
box-shadow:
|
||||||
|
0 15px 45px 0 rgba(19, 16, 16, 0.22),
|
||||||
|
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.13),
|
||||||
|
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.09);
|
||||||
|
|
||||||
|
/* animation: contentHide 300ms ease-in forwards; */
|
||||||
|
/**/
|
||||||
|
/* &[data-expanded] { */
|
||||||
|
/* animation: contentShow 300ms ease-out; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
[data-slot="header"] {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
padding: 4px 4px 4px 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
[data-slot="title"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
|
||||||
|
/* text-16-medium */
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-large);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: var(--line-height-x-large); /* 150% */
|
||||||
|
letter-spacing: var(--letter-spacing-tight);
|
||||||
|
}
|
||||||
|
/* [data-slot="close-button"] {} */
|
||||||
|
}
|
||||||
|
/* [data-slot="description"] {} */
|
||||||
|
[data-slot="body"] {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes overlayHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes contentShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes contentHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
packages/ui/src/components/dialog.tsx
Normal file
91
packages/ui/src/components/dialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Dialog as Kobalte,
|
||||||
|
DialogRootProps,
|
||||||
|
DialogTitleProps,
|
||||||
|
DialogCloseButtonProps,
|
||||||
|
DialogDescriptionProps,
|
||||||
|
} from "@kobalte/core/dialog"
|
||||||
|
import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||||
|
import { IconButton } from "./icon-button"
|
||||||
|
|
||||||
|
export interface DialogProps extends DialogRootProps {
|
||||||
|
trigger?: JSX.Element
|
||||||
|
class?: ComponentProps<"div">["class"]
|
||||||
|
classList?: ComponentProps<"div">["classList"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogRoot(props: DialogProps) {
|
||||||
|
let trigger!: HTMLElement
|
||||||
|
const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
|
||||||
|
|
||||||
|
const resetTabIndex = () => {
|
||||||
|
trigger.tabIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
|
||||||
|
const firstChild = e.currentTarget?.firstElementChild as HTMLElement
|
||||||
|
if (!firstChild) return
|
||||||
|
|
||||||
|
firstChild.focus()
|
||||||
|
trigger.tabIndex = -1
|
||||||
|
|
||||||
|
firstChild.addEventListener("focusout", resetTabIndex)
|
||||||
|
onCleanup(() => {
|
||||||
|
firstChild.removeEventListener("focusout", resetTabIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Kobalte {...others}>
|
||||||
|
<Show when={props.trigger}>
|
||||||
|
<Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
|
||||||
|
{props.trigger}
|
||||||
|
</Kobalte.Trigger>
|
||||||
|
</Show>
|
||||||
|
<Kobalte.Portal>
|
||||||
|
<Kobalte.Overlay data-component="dialog-overlay" />
|
||||||
|
<div data-component="dialog">
|
||||||
|
<div data-slot="container">
|
||||||
|
<Kobalte.Content
|
||||||
|
data-slot="content"
|
||||||
|
classList={{
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</Kobalte.Content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Kobalte.Portal>
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader(props: ComponentProps<"div">) {
|
||||||
|
return <div data-slot="header" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogBody(props: ComponentProps<"div">) {
|
||||||
|
return <div data-slot="body" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
|
||||||
|
return <Kobalte.Title data-slot="title" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
|
||||||
|
return <Kobalte.Description data-slot="description" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
|
||||||
|
return <Kobalte.CloseButton data-slot="close-button" as={IconButton} icon="close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dialog = Object.assign(DialogRoot, {
|
||||||
|
Header: DialogHeader,
|
||||||
|
Title: DialogTitle,
|
||||||
|
Description: DialogDescription,
|
||||||
|
CloseButton: DialogCloseButton,
|
||||||
|
Body: DialogBody,
|
||||||
|
})
|
||||||
117
packages/ui/src/components/icon-button.css
Normal file
117
packages/ui/src/components/icon-button.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
[data-component="icon-button"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--icon-strong-disabled);
|
||||||
|
color: var(--icon-invert-base);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant="primary"] {
|
||||||
|
background-color: var(--icon-strong-base);
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
/* color: var(--icon-weak-base); */
|
||||||
|
color: var(--icon-invert-base);
|
||||||
|
|
||||||
|
/* &:hover:not(:disabled) { */
|
||||||
|
/* color: var(--icon-weak-hover); */
|
||||||
|
/* } */
|
||||||
|
/* &:active:not(:disabled) { */
|
||||||
|
/* color: var(--icon-string-active); */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--icon-strong-hover);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: var(--icon-string-active);
|
||||||
|
}
|
||||||
|
&:focus:not(:disabled) {
|
||||||
|
background-color: var(--icon-strong-focus);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--icon-strong-disabled);
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
color: var(--icon-invert-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant="secondary"] {
|
||||||
|
background-color: var(--button-secondary-base);
|
||||||
|
color: var(--text-strong);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: var(--surface-active);
|
||||||
|
}
|
||||||
|
&:focus:not(:disabled) {
|
||||||
|
background-color: var(--surface-focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant="ghost"] {
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
color: var(--icon-weak-base);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--icon-weak-hover);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
color: var(--icon-string-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* color: var(--text-strong); */
|
||||||
|
/**/
|
||||||
|
/* &:hover:not(:disabled) { */
|
||||||
|
/* background-color: var(--surface-hover); */
|
||||||
|
/* } */
|
||||||
|
/* &:active:not(:disabled) { */
|
||||||
|
/* background-color: var(--surface-active); */
|
||||||
|
/* } */
|
||||||
|
/* &:focus:not(:disabled) { */
|
||||||
|
/* background-color: var(--surface-focus); */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="normal"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
line-height: var(--line-height-large);
|
||||||
|
gap: calc(var(--spacing) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="large"] {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px 0 6px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
/* text-12-medium */
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: var(--line-height-large); /* 166.667% */
|
||||||
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/ui/src/components/icon-button.tsx
Normal file
27
packages/ui/src/components/icon-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Button as Kobalte } from "@kobalte/core/button"
|
||||||
|
import { type ComponentProps, splitProps } from "solid-js"
|
||||||
|
import { Icon, IconProps } from "./icon"
|
||||||
|
|
||||||
|
export interface IconButtonProps {
|
||||||
|
icon: IconProps["name"]
|
||||||
|
size?: "normal" | "large"
|
||||||
|
variant?: "primary" | "secondary" | "ghost"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||||
|
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||||
|
return (
|
||||||
|
<Kobalte
|
||||||
|
{...rest}
|
||||||
|
data-component="icon-button"
|
||||||
|
data-size={split.size || "normal"}
|
||||||
|
data-variant={split.variant || "secondary"}
|
||||||
|
classList={{
|
||||||
|
...(split.classList ?? {}),
|
||||||
|
[split.class ?? ""]: !!split.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,4 +3,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* resize: both; */
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
color: var(--icon-base);
|
||||||
|
|
||||||
|
&[data-size="small"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="normal"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="large"] {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="svg"] {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,28 +128,55 @@ const icons = {
|
|||||||
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const newIcons = {
|
||||||
|
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
|
||||||
|
"magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
|
||||||
|
}
|
||||||
|
|
||||||
export interface IconProps extends ComponentProps<"svg"> {
|
export interface IconProps extends ComponentProps<"svg"> {
|
||||||
name: keyof typeof icons
|
name: keyof typeof icons | keyof typeof newIcons
|
||||||
size?: number
|
size?: "small" | "normal" | "large"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Icon(props: IconProps) {
|
export function Icon(props: IconProps) {
|
||||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||||
const size = local.size ?? 24
|
|
||||||
|
if (local.name in newIcons) {
|
||||||
|
return (
|
||||||
|
<div data-component="icon" data-size={local.size || "normal"}>
|
||||||
|
<svg
|
||||||
|
data-slot="svg"
|
||||||
|
classList={{
|
||||||
|
...(local.classList || {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
}}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
innerHTML={newIcons[local.name as keyof typeof newIcons]}
|
||||||
|
aria-hidden="true"
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<div data-component="icon" data-size={local.size || "normal"}>
|
||||||
data-component="icon"
|
<svg
|
||||||
classList={{
|
data-slot="svg"
|
||||||
...(local.classList || {}),
|
classList={{
|
||||||
[local.class ?? ""]: !!local.class,
|
...(local.classList || {}),
|
||||||
}}
|
[local.class ?? ""]: !!local.class,
|
||||||
width={size}
|
}}
|
||||||
height={size}
|
fill="none"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
innerHTML={icons[local.name as keyof typeof icons]}
|
||||||
innerHTML={icons[local.name]}
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
{...others}
|
||||||
{...others}
|
/>
|
||||||
/>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export * from "./button"
|
export * from "./button"
|
||||||
|
export * from "./dialog"
|
||||||
export * from "./icon"
|
export * from "./icon"
|
||||||
|
export * from "./icon-button"
|
||||||
|
export * from "./input"
|
||||||
export * from "./fonts"
|
export * from "./fonts"
|
||||||
export * from "./list"
|
export * from "./list"
|
||||||
export * from "./select"
|
export * from "./select"
|
||||||
|
export * from "./select-dialog"
|
||||||
export * from "./tabs"
|
export * from "./tabs"
|
||||||
export * from "./tooltip"
|
export * from "./tooltip"
|
||||||
|
|||||||
23
packages/ui/src/components/input.css
Normal file
23
packages/ui/src/components/input.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[data-component="input"] {
|
||||||
|
/* [data-slot="label"] {} */
|
||||||
|
|
||||||
|
[data-slot="input"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
|
||||||
|
/* text-14-regular */
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
line-height: var(--line-height-large); /* 142.857% */
|
||||||
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/ui/src/components/input.tsx
Normal file
27
packages/ui/src/components/input.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { TextField as Kobalte } from "@kobalte/core/text-field"
|
||||||
|
import { Show, splitProps } from "solid-js"
|
||||||
|
import type { ComponentProps } from "solid-js"
|
||||||
|
|
||||||
|
export interface InputProps extends ComponentProps<typeof Kobalte> {
|
||||||
|
label?: string
|
||||||
|
hideLabel?: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input(props: InputProps) {
|
||||||
|
const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
|
||||||
|
return (
|
||||||
|
<Kobalte {...others} data-component="input">
|
||||||
|
<Show when={local.label}>
|
||||||
|
<Kobalte.Label data-slot="label" classList={{ "sr-only": local.hideLabel }}>
|
||||||
|
{local.label}
|
||||||
|
</Kobalte.Label>
|
||||||
|
</Show>
|
||||||
|
<Kobalte.Input data-slot="input" class={local.class} placeholder={local.placeholder} />
|
||||||
|
<Show when={local.description}>
|
||||||
|
<Kobalte.Description data-slot="description">{local.description}</Kobalte.Description>
|
||||||
|
</Show>
|
||||||
|
<Kobalte.ErrorMessage data-slot="error" />
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
[data-slot="item"] {
|
[data-slot="item"] {
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -23,6 +22,9 @@
|
|||||||
&[data-active="true"] {
|
&[data-active="true"] {
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-raised-base-hover);
|
||||||
}
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-raised-base-hover);
|
||||||
|
}
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function List<T>(props: ListProps<T>) {
|
|||||||
// }
|
// }
|
||||||
const handleSelect = (item: T) => {
|
const handleSelect = (item: T) => {
|
||||||
props.onSelect?.(item)
|
props.onSelect?.(item)
|
||||||
|
list.setActive(props.key(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -64,10 +65,10 @@ export function List<T>(props: ListProps<T>) {
|
|||||||
data-key={props.key(item)}
|
data-key={props.key(item)}
|
||||||
data-active={props.key(item) === list.active()}
|
data-active={props.key(item) === list.active()}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={() => {
|
||||||
e.currentTarget.focus()
|
// e.currentTarget.focus()
|
||||||
setStore("mouseActive", true)
|
setStore("mouseActive", true)
|
||||||
list.setActive(props.key(item))
|
// list.setActive(props.key(item))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children(item)}
|
{props.children(item)}
|
||||||
|
|||||||
109
packages/ui/src/components/select-dialog.css
Normal file
109
packages/ui/src/components/select-dialog.css
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
[data-component="select-dialog-input"] {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 10px 4px 6px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
|
||||||
|
[data-slot="input-container"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1 0 0;
|
||||||
|
|
||||||
|
/* [data-slot="icon"] {} */
|
||||||
|
|
||||||
|
[data-slot="input"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [data-slot="clear-button"] {} */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="select-dialog"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
[data-slot="empty-state"] {
|
||||||
|
display: flex;
|
||||||
|
padding: 32px 160px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
[data-slot="message"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--text-weak);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
/* text-14-regular */
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
line-height: var(--line-height-large); /* 142.857% */
|
||||||
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="filter"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="group"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
[data-slot="header"] {
|
||||||
|
display: flex;
|
||||||
|
padding: 4px 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
color: var(--text-weak);
|
||||||
|
|
||||||
|
/* text-12-medium */
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: var(--line-height-large); /* 166.667% */
|
||||||
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
[data-slot="item"] {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&[data-active="true"] {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-raised-base-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
packages/ui/src/components/select-dialog.tsx
Normal file
156
packages/ui/src/components/select-dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
|
||||||
|
import { Dialog, DialogProps, Icon, IconButton, Input } from "@opencode-ai/ui"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
|
||||||
|
interface SelectDialogProps<T>
|
||||||
|
extends FilteredListProps<T>,
|
||||||
|
Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
|
||||||
|
title: string
|
||||||
|
placeholder?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
children: (item: T) => JSX.Element
|
||||||
|
onSelect?: (value: T | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||||
|
const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
|
||||||
|
let closeButton!: HTMLButtonElement
|
||||||
|
let scrollRef: HTMLDivElement | undefined
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
mouseActive: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
|
||||||
|
items: others.items,
|
||||||
|
key: others.key,
|
||||||
|
filterKeys: others.filterKeys,
|
||||||
|
current: others.current,
|
||||||
|
groupBy: others.groupBy,
|
||||||
|
sortBy: others.sortBy,
|
||||||
|
sortGroupsBy: others.sortGroupsBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
filter()
|
||||||
|
scrollRef?.scrollTo(0, 0)
|
||||||
|
reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const all = flat()
|
||||||
|
if (store.mouseActive || all.length === 0) return
|
||||||
|
if (active() === others.key(all[0])) {
|
||||||
|
scrollRef?.scrollTo(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
|
||||||
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleInput = (value: string) => {
|
||||||
|
onInput(value)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (item: T | undefined) => {
|
||||||
|
others.onSelect?.(item)
|
||||||
|
closeButton.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
setStore("mouseActive", false)
|
||||||
|
if (e.key === "Escape") return
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
const selected = flat().find((x) => others.key(x) === active())
|
||||||
|
if (selected) handleSelect(selected)
|
||||||
|
} else {
|
||||||
|
onKeyDown(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) clear()
|
||||||
|
props.onOpenChange?.(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog modal {...dialog} onOpenChange={handleOpenChange}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{others.title}</Dialog.Title>
|
||||||
|
<Dialog.CloseButton ref={closeButton} style={{ display: "none" }} />
|
||||||
|
</Dialog.Header>
|
||||||
|
<div data-component="select-dialog-input">
|
||||||
|
<div data-slot="input-container">
|
||||||
|
<Icon data-slot="icon" name="magnifying-glass" />
|
||||||
|
<Input
|
||||||
|
data-slot="input"
|
||||||
|
type="text"
|
||||||
|
value={filter()}
|
||||||
|
onChange={(value) => handleInput(value)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
placeholder={others.placeholder}
|
||||||
|
autofocus
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={filter()}>
|
||||||
|
<IconButton
|
||||||
|
data-slot="clear-button"
|
||||||
|
icon="circle-x"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onInput("")
|
||||||
|
reset()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Dialog.Body ref={scrollRef} data-component="select-dialog" class="no-scrollbar">
|
||||||
|
<Show
|
||||||
|
when={flat().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div data-slot="empty-state">
|
||||||
|
<div data-slot="message">
|
||||||
|
{props.emptyMessage ?? "No search results"} for <span data-slot="filter">"{filter()}"</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={grouped()}>
|
||||||
|
{(group) => (
|
||||||
|
<div data-slot="group">
|
||||||
|
<Show when={group.category}>
|
||||||
|
<div data-slot="header">{group.category}</div>
|
||||||
|
</Show>
|
||||||
|
<div data-slot="list">
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
data-slot="item"
|
||||||
|
data-key={others.key(item)}
|
||||||
|
data-active={others.key(item) === active()}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
onMouseMove={() => {
|
||||||
|
setStore("mouseActive", true)
|
||||||
|
setActive(others.key(item))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{others.children(item)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Dialog.Body>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[data-component="select"] {
|
[data-component="select"] {
|
||||||
[data-slot="trigger"] {
|
[data-slot="trigger"] {
|
||||||
padding: 0 4px 0 8px;
|
padding: 0 4px 0 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
[data-slot="value"] {
|
[data-slot="value"] {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -8,8 +9,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-slot="icon"] {
|
[data-slot="icon"] {
|
||||||
width: fit-content;
|
width: 16px;
|
||||||
height: fit-content;
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
transition: transform 0.1s ease-in-out;
|
transition: transform 0.1s ease-in-out;
|
||||||
@@ -18,15 +19,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-component="select-content"] {
|
[data-component="select-content"] {
|
||||||
min-width: 8rem;
|
min-width: 4rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--radius-md);
|
border-radius: 8px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--border-weak-base);
|
border-color: var(--border-weak-base);
|
||||||
background-color: var(--surface-raised-base);
|
background-color: var(--surface-raised-stronger-non-alpha);
|
||||||
padding: calc(var(--spacing) * 1);
|
padding: 2px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-xs);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
|
||||||
&[data-closed] {
|
&[data-closed] {
|
||||||
@@ -42,36 +43,35 @@
|
|||||||
max-height: 12rem;
|
max-height: 12rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="section"] {
|
/* [data-slot="section"] { */
|
||||||
font-size: var(--text-xs);
|
/* } */
|
||||||
line-height: var(--text-xs--line-height);
|
|
||||||
font-weight: var(--font-weight-light);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-weak);
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-top: calc(var(--spacing) * 3);
|
|
||||||
margin-left: calc(var(--spacing) * 2);
|
|
||||||
&:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="item"] {
|
[data-slot="item"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
padding: 0 6px 0 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 6px;
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: var(--text-xs--line-height);
|
/* text-12-medium */
|
||||||
color: var(--text-base);
|
font-family: var(--font-family-sans);
|
||||||
cursor: pointer;
|
font-size: var(--font-size-small);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: var(--line-height-large); /* 166.667% */
|
||||||
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
|
||||||
|
color: var(--text-strong);
|
||||||
|
|
||||||
transition:
|
transition:
|
||||||
background-color 0.2s ease-in-out,
|
background-color 0.2s ease-in-out,
|
||||||
color 0.2s ease-in-out;
|
color 0.2s ease-in-out;
|
||||||
@@ -79,24 +79,20 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&[data-highlighted] {
|
&[data-highlighted] {
|
||||||
background-color: var(--surface-base);
|
background: var(--surface-raised-base-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-disabled] {
|
&[data-disabled] {
|
||||||
background-color: var(--surface-disabled);
|
background-color: var(--surface-raised-base);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="item-indicator"] {
|
[data-slot="item-indicator"] {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--surface-hover);
|
background: var(--surface-raised-base-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
|||||||
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
||||||
</Kobalte.ItemLabel>
|
</Kobalte.ItemLabel>
|
||||||
<Kobalte.ItemIndicator data-slot="item-indicator">
|
<Kobalte.ItemIndicator data-slot="item-indicator">
|
||||||
<Icon name="checkmark" size={16} />
|
<Icon name="checkmark" />
|
||||||
</Kobalte.ItemIndicator>
|
</Kobalte.ItemIndicator>
|
||||||
</Kobalte.Item>
|
</Kobalte.Item>
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
|||||||
}}
|
}}
|
||||||
</Kobalte.Value>
|
</Kobalte.Value>
|
||||||
<Kobalte.Icon data-slot="icon">
|
<Kobalte.Icon data-slot="icon">
|
||||||
<Icon name="chevron-down" size={16} />
|
<Icon name="chevron-down" size="small" />
|
||||||
</Kobalte.Icon>
|
</Kobalte.Icon>
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
background-color: var(--background-stronger);
|
background-color: var(--background-stronger);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
& [data-slot="list"] {
|
[data-slot="list"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& [data-slot="trigger"] {
|
[data-slot="trigger"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--border-weak-base);
|
border-bottom: 1px solid var(--border-weak-base);
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& [data-slot="content"] {
|
[data-slot="content"] {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) {
|
|||||||
<KobalteTooltip.Portal>
|
<KobalteTooltip.Portal>
|
||||||
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
|
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
|
||||||
{typeof others.value === "function" ? others.value() : others.value}
|
{typeof others.value === "function" ? others.value() : others.value}
|
||||||
{/* <KobalteTooltip.Arrow data-slot="arrow" size={18} /> */}
|
{/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
|
||||||
</KobalteTooltip.Content>
|
</KobalteTooltip.Content>
|
||||||
</KobalteTooltip.Portal>
|
</KobalteTooltip.Portal>
|
||||||
</KobalteTooltip>
|
</KobalteTooltip>
|
||||||
|
|||||||
1
packages/ui/src/hooks/index.ts
Normal file
1
packages/ui/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-filtered-list"
|
||||||
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||||
|
import { createMemo, createResource } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createList } from "solid-list"
|
||||||
|
|
||||||
|
export interface FilteredListProps<T> {
|
||||||
|
items: T[] | ((filter: string) => Promise<T[]>)
|
||||||
|
key: (item: T) => string
|
||||||
|
filterKeys?: string[]
|
||||||
|
current?: T
|
||||||
|
groupBy?: (x: T) => string
|
||||||
|
sortBy?: (a: T, b: T) => number
|
||||||
|
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
||||||
|
onSelect?: (value: T | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||||
|
const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
|
||||||
|
|
||||||
|
const [grouped] = createResource(
|
||||||
|
() => store.filter,
|
||||||
|
async (filter) => {
|
||||||
|
const needle = filter?.toLowerCase()
|
||||||
|
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||||
|
const result = pipe(
|
||||||
|
all,
|
||||||
|
(x) => {
|
||||||
|
if (!needle) return x
|
||||||
|
if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||||
|
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||||
|
}
|
||||||
|
return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj)
|
||||||
|
},
|
||||||
|
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||||
|
entries(),
|
||||||
|
map(([k, v]) => ({ category: k, items: props.sortBy ? v.sort(props.sortBy) : v })),
|
||||||
|
(groups) => (props.sortGroupsBy ? groups.sort(props.sortGroupsBy) : groups),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const flat = createMemo(() => {
|
||||||
|
return pipe(
|
||||||
|
grouped() || [],
|
||||||
|
flatMap((x) => x.items),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createList({
|
||||||
|
items: () => flat().map(props.key),
|
||||||
|
initialActive: props.current ? props.key(props.current) : props.key(flat()[0]),
|
||||||
|
loop: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
const all = flat()
|
||||||
|
if (all.length === 0) return
|
||||||
|
list.setActive(props.key(all[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
const selected = flat().find((x) => props.key(x) === list.active())
|
||||||
|
if (selected) props.onSelect?.(selected)
|
||||||
|
} else {
|
||||||
|
list.onKeyDown(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (value: string) => {
|
||||||
|
setStore("filter", value)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter: () => store.filter,
|
||||||
|
grouped,
|
||||||
|
flat,
|
||||||
|
reset,
|
||||||
|
clear: () => setStore("filter", ""),
|
||||||
|
onKeyDown,
|
||||||
|
onInput,
|
||||||
|
active: list.active,
|
||||||
|
setActive: list.setActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
@import "./base.css" layer(base);
|
@import "./base.css" layer(base);
|
||||||
|
|
||||||
@import "../components/button.css" layer(components);
|
@import "../components/button.css" layer(components);
|
||||||
|
@import "../components/dialog.css" layer(components);
|
||||||
@import "../components/icon.css" layer(components);
|
@import "../components/icon.css" layer(components);
|
||||||
|
@import "../components/icon-button.css" layer(components);
|
||||||
|
@import "../components/input.css" layer(components);
|
||||||
@import "../components/list.css" layer(components);
|
@import "../components/list.css" layer(components);
|
||||||
@import "../components/select.css" layer(components);
|
@import "../components/select.css" layer(components);
|
||||||
|
@import "../components/select-dialog.css" layer(components);
|
||||||
@import "../components/tabs.css" layer(components);
|
@import "../components/tabs.css" layer(components);
|
||||||
@import "../components/tooltip.css" layer(components);
|
@import "../components/tooltip.css" layer(components);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
/* ::selection { */
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
|
/* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */
|
||||||
/* background-color: var(--color-primary); */
|
/* background-color: var(--color-primary); */
|
||||||
/* color: var(--color-background); */
|
/* color: var(--color-background); */
|
||||||
}
|
/* } */
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--theme-background-panel);
|
background: var(--theme-background-panel);
|
||||||
@@ -36,6 +36,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.text-12-regular {
|
.text-12-regular {
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
|
|||||||
Reference in New Issue
Block a user