diff --git a/bun.lock b/bun.lock index cd3b46eb28..7ba981d962 100644 --- a/bun.lock +++ b/bun.lock @@ -291,8 +291,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.72", - "@opentui/solid": "0.1.72", + "@opentui/core": "0.1.73", + "@opentui/solid": "0.1.73", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1216,21 +1216,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="], + "@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="], - "@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="], + "@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/flake.lock b/flake.lock index 5ef276f0a0..58bdca6bf6 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768302833, - "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", + "lastModified": 1768395095, + "narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "61db79b0c6b838d9894923920b612048e1201926", + "rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 2792318be1..df85d88394 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-GKdu7nan/9ioBtgL3cUeuVLNKUDio10LeQrn7BPgbng=", - "aarch64-darwin": "sha256-STLB1J65VjauvPM+BqCyTQQkHPoVmUhDvVEdH3WTJP4=" + "x86_64-linux": "sha256-XP1DXs1Fcfog99rjMryki9mMqn1g1H4ykHx7WDsnrnw=", + "aarch64-darwin": "sha256-fupiqvXkW3Cl44K+n1cDz81vOboMXIHPHTey6TewX70=" } } diff --git a/packages/app/index.html b/packages/app/index.html index e0fbe6913d..450807a42e 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -13,12 +13,11 @@ - -
+
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cfc6eb4387..62d29c9e3f 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,267 +1,203 @@ import { createMemo, createResource, Show } from "solid-js" -import { A, useNavigate, useParams } from "@solidjs/router" +import { Portal } from "solid-js/web" +import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" -import { useServer } from "@/context/server" -import { useDialog } from "@opencode-ai/ui/context/dialog" +// import { useServer } from "@/context/server" +// import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { base64Decode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Select } from "@opencode-ai/ui/select" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { SessionLspIndicator } from "@/components/session-lsp-indicator" -import { SessionMcpIndicator } from "@/components/session-mcp-indicator" -import type { Session } from "@opencode-ai/sdk/v2/client" -import { same } from "@/utils/same" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() - const navigate = useNavigate() const command = useCommand() - const server = useServer() - const dialog = useDialog() + // const server = useServer() + // const dialog = useDialog() const sync = useSync() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) - - const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) - const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) - const parentSession = createMemo(() => { - const current = currentSession() - if (!current?.parentID) return undefined - return sync.data.session.find((s) => s.id === current.parentID) + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) + + const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) - function navigateToProject(directory: string) { - navigate(`/${base64Encode(directory)}`) - } - - function navigateToSession(session: Session | undefined) { - if (!session) return - // Only navigate if we're actually changing to a different session - if (session.id === params.id) return - navigate(`/${params.dir}/session/${session.id}`) - } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( -
- -
-
-
- - - x.title} - value={(x) => x.id} - onSelect={(session) => { - // Only navigate if selecting a different session than current parent - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } - }} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" - variant="ghost" - /> -
/
-
- - + + )} + + + {(mount) => ( + +
+ {/* */} +
+ +
-
-
-
- - - -
-
- -
- - + + - - -
- - - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) - } - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } - - ) - })} - - -
-
-
+ + + + + + + + } + > + {iife(() => { + const [url] = createResource( + () => currentSession(), + async (session) => { + if (!session) return + let shareURL = session.share?.url + if (!shareURL) { + shareURL = await globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) + } + return shareURL + }, + { initialValue: "" }, + ) + return ( + + {(shareUrl) => } + + ) + })} + + + + + )} + + ) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx new file mode 100644 index 0000000000..5cf9f74bc9 --- /dev/null +++ b/packages/app/src/components/titlebar.tsx @@ -0,0 +1,115 @@ +import { createEffect, createMemo, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme" + +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" + +export function Titlebar() { + const layout = useLayout() + const platform = usePlatform() + const command = useCommand() + const theme = useTheme() + + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserve = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + + const getWin = () => { + if (platform.platform !== "desktop") return + + const tauri = ( + window as unknown as { + __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + } + ).__TAURI__ + if (!tauri?.window?.getCurrentWindow) return + + return tauri.window.getCurrentWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) + .__TAURI__ + const get = tauri?.webviewWindow?.getCurrentWebviewWindow + if (!get) return + + const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise } + if (!win.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + return ( +
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ea0b90d5de..a0b2570568 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -124,12 +124,19 @@ function createGlobalSync() { return globalSDK.client.session .list({ directory, roots: true }) .then((x) => { - const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .slice() .sort((a, b) => a.id.localeCompare(b.id)) + + const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) + if (sandboxWorkspace) { + setStore("session", reconcile(nonArchived, { key: "id" })) + return + } + + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 // Include up to the limit, plus any updated in the last 4 hours const sessions = nonArchived.filter((s, i) => { if (i < limit) return true diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa5..a49e891bf8 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -47,12 +47,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const globalSdk = useGlobalSDK() const globalSync = useGlobalSync() const server = useServer() + + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + + const migrate = (value: unknown) => { + if (!isRecord(value)) return value + const sidebar = value.sidebar + if (!isRecord(sidebar)) return value + if (typeof sidebar.workspaces !== "boolean") return value + return { + ...value, + sidebar: { + ...sidebar, + workspaces: {}, + workspacesDefault: sidebar.workspaces, + }, + } + } + + const target = Persist.global("layout", ["layout.v6"]) const [store, setStore, _, ready] = persisted( - Persist.global("layout", ["layout.v6"]), + { ...target, migrate }, createStore({ sidebar: { opened: false, width: 280, + workspaces: {} as Record, + workspacesDefault: false, }, terminal: { height: 280, @@ -304,6 +326,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, + workspaces(directory: string) { + return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false) + }, + setWorkspaces(directory: string, value: boolean) { + setStore("sidebar", "workspaces", directory, value) + }, + toggleWorkspaces(directory: string) { + const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + setStore("sidebar", "workspaces", directory, !current) + }, }, terminal: { height: createMemo(() => store.terminal.height), diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b0822e7078..6d2d3db060 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,9 @@ export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" + /** Desktop OS (Tauri only) */ + os?: "macos" | "windows" | "linux" + /** App version */ version?: string diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index e5f2c076ed..33129e1b47 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - const chunk = 200 + const chunk = 400 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b1..d9d51aa8fb 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,7 @@ cursor: default; } } + +*[data-tauri-drag-region] { + app-region: drag; +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 39f397ac46..82a95b533e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,6 @@ import { untrack, type JSX, } from "solid-js" -import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -24,12 +23,12 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" -import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" @@ -53,27 +52,24 @@ import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, - activeDraggable: undefined as string | undefined, - mobileProjectsExpanded: {} as Record, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceExpanded: {} as Record, }) - const mobileProjects = { - expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, - expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), - collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), - } - let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) @@ -275,17 +271,59 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) - function projectSessions(project: LocalProject | undefined) { - if (!project) return [] - const dirs = [project.worktree, ...(project.sandboxes ?? [])] - const stores = dirs.map((dir) => globalSync.child(dir)[0]) - const sessions = stores - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions) - return sessions.filter((s) => !s.parentID) - } + const workspaceSetting = createMemo(() => { + const project = currentProject() + if (!project) return false + return layout.sidebar.workspaces(project.worktree)() + }) - const currentSessions = createMemo(() => projectSessions(currentProject())) + createEffect(() => { + const project = currentProject() + if (!project) return + + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) { + setStore("workspaceOrder", project.worktree, dirs) + return + } + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + const merged = [...keep, ...missing] + + if (merged.length !== existing.length) { + setStore("workspaceOrder", project.worktree, merged) + return + } + + if (merged.some((d, i) => d !== existing[i])) { + setStore("workspaceOrder", project.worktree, merged) + } + }) + + const currentSessions = createMemo(() => { + const project = currentProject() + if (!project) return [] as Session[] + if (workspaceSetting()) { + const dirs = workspaceIds(project) + const result: Session[] = [] + for (const dir of dirs) { + const [dirStore] = globalSync.child(dir) + const dirSessions = dirStore.session + .filter((session) => session.directory === dirStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + result.push(...dirSessions) + } + return result + } + const [projectStore] = globalSync.child(project.worktree) + return projectStore.session + .filter((session) => session.directory === projectStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + }) type PrefetchQueue = { inflight: Set @@ -325,7 +363,7 @@ export default function Layout(props: ParentProps) { return created } - const prefetchMessages = (directory: string, sessionID: string, token: number) => { + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) @@ -428,89 +466,44 @@ export default function Layout(props: ParentProps) { }) function navigateSessionByOffset(offset: number) { - const projects = layout.projects.list() - if (projects.length === 0) return - - const project = currentProject() - const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1 - - if (projectIndex === -1) { - const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] - if (targetProject) navigateToProject(targetProject.worktree) - return - } - const sessions = currentSessions() + if (sessions.length === 0) return + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 let targetIndex: number if (sessionIndex === -1) { targetIndex = offset > 0 ? 0 : sessions.length - 1 } else { - targetIndex = sessionIndex + offset + targetIndex = (sessionIndex + offset + sessions.length) % sessions.length } - if (targetIndex >= 0 && targetIndex < sessions.length) { - const session = sessions[targetIndex] - const next = sessions[targetIndex + 1] - const prev = sessions[targetIndex - 1] + const session = sessions[targetIndex] + if (!session) return - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } - - if (import.meta.env.DEV) { - navStart({ - dir: base64Encode(session.directory), - from: params.id, - to: session.id, - trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", - }) - } - navigateToSession(session) - queueMicrotask(() => scrollToSession(session.id)) - return - } - - const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) - const nextProject = projects[nextProjectIndex] - if (!nextProject) return - - const nextProjectSessions = projectSessions(nextProject) - if (nextProjectSessions.length === 0) { - navigateToProject(nextProject.worktree) - return - } - - const index = offset > 0 ? 0 : nextProjectSessions.length - 1 - const targetSession = nextProjectSessions[index] - const nextSession = nextProjectSessions[index + 1] - const prevSession = nextProjectSessions[index - 1] + const next = sessions[(targetIndex + 1) % sessions.length] + const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] if (offset > 0) { - if (nextSession) prefetchSession(nextSession, "high") + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) } if (offset < 0) { - if (prevSession) prefetchSession(prevSession, "high") + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) } if (import.meta.env.DEV) { navStart({ - dir: base64Encode(targetSession.directory), + dir: base64Encode(session.directory), from: params.id, - to: targetSession.id, + to: session.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } - navigateToSession(targetSession) - queueMicrotask(() => scrollToSession(targetSession.id)) + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id)) } async function archiveSession(session: Session) { @@ -704,18 +697,32 @@ export default function Layout(props: ParentProps) { const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) - const project = currentProject() - untrack(() => layout.projects.expand(project?.worktree ?? directory)) + untrack(() => setStore("workspaceExpanded", directory, true)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { - if (isLargeViewport()) { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 - document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) - } else { - document.documentElement.style.setProperty("--dialog-left-margin", "0px") + const project = currentProject() + if (!project) return + + if (workspaceSetting()) { + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of dirs) { + globalSync.project.loadSessions(directory) + } + return } + + globalSync.project.loadSessions(project.worktree) + }) + + createEffect(() => { + if (isLargeViewport()) { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + return + } + document.documentElement.style.setProperty("--dialog-left-margin", "0px") }) function getDraggableId(event: unknown): string | undefined { @@ -729,7 +736,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setStore("activeDraggable", id) + setStore("activeProject", id) } function handleDragOver(event: DragEvent) { @@ -745,44 +752,77 @@ export default function Layout(props: ParentProps) { } function handleDragEnd() { - setStore("activeDraggable", undefined) + setStore("activeProject", undefined) } - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) return dirs + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + return [...keep, ...missing] + } + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = currentProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore("workspaceOrder", project.worktree, result) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) + } + + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( -
- 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined - } - /> - -