diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx new file mode 100644 index 000000000000..21802201c72b --- /dev/null +++ b/packages/app/src/components/file-tree-v2.tsx @@ -0,0 +1,511 @@ +import { useFile } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import "@opencode-ai/ui/v2/file-tree-v2.css" +import { + createEffect, + createMemo, + For, + Match, + on, + Show, + splitProps, + Switch, + untrack, + type ComponentProps, + type ParentProps, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" +import { dirsToExpand, shouldListRoot } from "@/components/file-tree" + +const MAX_DEPTH = 128 + +function pathToFileUrl(filepath: string): string { + return `file://${encodeFilePath(filepath)}` +} + +type Kind = "add" | "del" | "mix" + +type Filter = { + files: Set + dirs: Set +} + +function visibleNodesForPath( + path: string, + children: (dir: string) => FileNode[], + current: Filter | undefined, + query?: string, +) { + const nodes = children(path) + if (!current) { + const value = query?.trim().toLowerCase() + if (!value) return nodes + return nodes.filter((node) => { + if (node.type === "directory") return true + return node.name.toLowerCase().includes(value) + }) + } + + const parent = (item: string) => { + const idx = item.lastIndexOf("/") + if (idx === -1) return "" + return item.slice(0, idx) + } + + const leaf = (item: string) => { + const idx = item.lastIndexOf("/") + return idx === -1 ? item : item.slice(idx + 1) + } + + const out = nodes.filter((node) => { + if (node.type === "file") return current.files.has(node.path) + return current.dirs.has(node.path) + }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + out.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + const value = query?.trim().toLowerCase() + if (!value) return out + + return out.filter((node) => { + if (node.type === "directory") return true + return node.name.toLowerCase().includes(value) + }) +} + +const INDENT_STEP = 16 + +function rowPaddingLeft(level: number, type: FileNode["type"]) { + if (type === "directory") return 8 + level * INDENT_STEP + if (level === 0) return 8 + return 8 + level * INDENT_STEP - INDENT_STEP +} + +function guideLineLeft(level: number) { + return rowPaddingLeft(level, "directory") + 8 +} + +type ChangeState = "modified" | "added" | "deleted" | "renamed" | "untracked" + +const kindLabel = (kind: Kind, showModifiedLabel: boolean) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + if (showModifiedLabel) return "M" + return "" +} + +const kindChangeState = (kind: Kind): ChangeState => { + if (kind === "add") return "added" + if (kind === "del") return "deleted" + return "modified" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNodeV2 = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap + marks?: Set + showModifiedLabel?: boolean + showFolderChangeIndicator?: boolean + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "showModifiedLabel", + "showFolderChangeIndicator", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + {local.node.name} + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value, local.showModifiedLabel ?? false)} + + ) + } + if (local.showFolderChangeIndicator === false) return null + return + ) +} + +export default function FileTreeV2(props: { + path: string + class?: string + nodeClass?: string + active?: string + level?: number + allowed?: readonly string[] + modified?: readonly string[] + kinds?: ReadonlyMap + query?: string + showModifiedLabel?: boolean + showFolderChangeIndicator?: boolean + draggable?: boolean + onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map + _kinds?: ReadonlyMap + _chain?: readonly string[] +}) { + const file = useFile() + const level = props.level ?? 0 + const draggable = () => props.draggable ?? true + + const key = (p: string) => + file + .normalize(p) + .replace(/[\\/]+$/, "") + .replaceAll("\\", "/") + const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] + + const filter = createMemo(() => { + if (props._filter) return props._filter + + const allowed = props.allowed + if (!allowed) return + + const files = new Set(allowed) + const dirs = new Set() + + for (const item of allowed) { + const parts = item.split("/") + const parents = parts.slice(0, -1) + for (const [idx] of parents.entries()) { + const dir = parents.slice(0, idx + 1).join("/") + if (dir) dirs.add(dir) + } + } + + return { files, dirs } + }) + + const marks = createMemo(() => { + if (props._marks) return props._marks + + const out = new Set() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + if (out.size === 0) return + return out + }) + + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds + }) + + const deeps = createMemo(() => { + if (props._deeps) return props._deeps + + const out = new Map() + + const root = props.path + if (!(file.tree.state(root)?.expanded ?? false)) return out + + const seen = new Set() + const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] + + const push = (dir: string, lvl: number) => { + const id = key(dir) + if (seen.has(id)) return + seen.add(id) + + const kids = file.tree + .children(dir) + .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) + .map((node) => node.path) + + stack.push({ dir, lvl, i: 0, kids, max: lvl }) + } + + push(root, level - 1) + + while (stack.length > 0) { + const top = stack[stack.length - 1]! + + if (top.i < top.kids.length) { + const next = top.kids[top.i]! + top.i++ + push(next, top.lvl + 1) + continue + } + + out.set(top.dir, top.max) + stack.pop() + + const parent = stack[stack.length - 1] + if (!parent) continue + parent.max = Math.max(parent.max, top.max) + } + + return out + }) + + createEffect(() => { + const current = filter() + const dirs = dirsToExpand({ + level, + filter: current, + expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + }) + for (const dir of dirs) file.tree.expand(dir) + }) + + createEffect( + on( + () => props.path, + (path) => { + const dir = untrack(() => file.tree.state(path)) + if (!shouldListRoot({ level, dir })) return + void file.tree.list(path) + }, + { defer: false }, + ), + ) + + const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter(), props.query)) + + return ( +
+ + {(node) => { + const expanded = () => file.tree.state(node.path)?.expanded ?? false + const deep = () => deeps().get(node.path) ?? -1 + const hasChildren = () => + visibleNodesForPath(node.path, file.tree.children, filter(), props.query).length > 0 + return ( + + + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + +
+ +
+
+
+ + +
+ ...
} + > + +
+ + +
+
+ + props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} + > + 0}> +
+ + + } + > + + + + + + + + + ) + }} + +
+ ) +} diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 8e424f0f36f7..b5eb690ccf87 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,8 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" +export { SortableTerminalTabV2 } from "./session-sortable-terminal-tab-v2" export { NewSessionView } from "./session-new-view" export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx new file mode 100644 index 000000000000..df633a8ded8c --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -0,0 +1,92 @@ +import { For, Show } from "solid-js" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { OPEN_APPS, type OpenApp, useOpenInApp } from "@/components/session/open-in-app" + +export function OpenInAppV2(props: { directory: () => string }) { + const language = useLanguage() + const state = useOpenInApp(props) + + return ( + + + + state.openDir(state.current().id)} + disabled={state.opening()} + aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} + > + }> + + + + + state.setMenu("open", open)} + > + + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + state.setMenu("open", false) + state.openDir(option.id) + }} + > + + {option.label} + + )} + + + + + { + state.setMenu("open", false) + state.copyPath() + }} + > + + {language.t("session.header.open.copyPath")} + + + + + + + ) +} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 000000000000..0d5cd9067122 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,232 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" + +export const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +export type OpenApp = (typeof OPEN_APPS)[number] +export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" + +export const MAC_OPEN_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const WINDOWS_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const LINUX_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export function detectOpenAppOS(platform: ReturnType): OpenAppOS { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function openAppFileManager(os: OpenAppOS) { + if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } +} + +export function openAppsForOS(os: OpenAppOS) { + if (os === "macos") return MAC_OPEN_APPS + if (os === "windows") return WINDOWS_OPEN_APPS + return LINUX_OPEN_APPS +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function useOpenInApp(input: { directory: () => string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const os = createMemo(() => detectOpenAppOS(platform)) + const apps = createMemo(() => openAppsForOS(os())) + const fileManager = createMemo(() => openAppFileManager(os())) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const [prefs, setPrefs] = persisted( + Persist.global("open.app"), + createStore({ app: "finder" as OpenApp | "finder" }), + ) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp | "finder") => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp | "finder") => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = input.directory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = input.directory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + return { + canOpen, + opening, + current, + options, + menu, + setMenu, + openDir, + selectApp, + copyPath, + } +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b983633d8069..7ee6cff31dd4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -5,136 +5,29 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" -import { showToast } from "@/utils/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" -import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, createSignal, For, onMount, Show } from "solid-js" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { focusTerminalById } from "@/pages/session/helpers" +import { focusTerminalById, toggleSessionTerminal } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" -import { Persist, persisted } from "@/utils/persist" +import { OPEN_APPS, type OpenApp, useOpenInApp } from "@/components/session/open-in-app" import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -const OPEN_APPS = [ - "vscode", - "cursor", - "zed", - "textmate", - "antigravity", - "finder", - "terminal", - "iterm2", - "ghostty", - "warp", - "xcode", - "android-studio", - "powershell", - "sublime-text", -] as const - -type OpenApp = (typeof OPEN_APPS)[number] -type OS = "macos" | "windows" | "linux" | "unknown" - -const MAC_APPS = [ - { - id: "vscode", - label: "session.header.open.app.vscode", - icon: "vscode", - openWith: "Visual Studio Code", - }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, - { - id: "antigravity", - label: "session.header.open.app.antigravity", - icon: "antigravity", - openWith: "Antigravity", - }, - { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, - { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, - { - id: "android-studio", - label: "session.header.open.app.androidStudio", - icon: "android-studio", - openWith: "Android Studio", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const WINDOWS_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "powershell", - label: "session.header.open.app.powershell", - icon: "powershell", - openWith: "powershell", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const LINUX_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const detectOS = (platform: ReturnType): OS => { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" -} - -const showRequestError = (language: ReturnType, err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) -} - export function SessionHeader() { const layout = useLayout() const command = useCommand() - const server = useServer() const platform = usePlatform() const language = useLanguage() const settings = useSettings() @@ -154,61 +47,16 @@ export function SessionHeader() { return getFilename(projectDirectory()) }) const hotkey = createMemo(() => command.keybind("file.open")) - const os = createMemo(() => detectOS(platform)) + const openIn = useOpenInApp({ directory: projectDirectory }) const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns()) const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true)) const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true)) const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true)) const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true)) - const [exists, setExists] = createStore>>({ - finder: true, - }) - - const apps = createMemo(() => { - if (os() === "macos") return MAC_APPS - if (os() === "windows") return WINDOWS_APPS - return LINUX_APPS - }) - - const fileManager = createMemo(() => { - if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const } - if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } - return { label: "session.header.open.fileManager", icon: "finder" as const } - }) - - createEffect(() => { - if (platform.platform !== "desktop") return - if (!platform.checkAppExists) return - - const list = apps() - - setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) - - void Promise.all( - list.map((app) => - Promise.resolve(platform.checkAppExists?.(app.openWith)) - .then((value) => Boolean(value)) - .catch(() => false) - .then((ok) => [app.id, ok] as const), - ), - ).then((entries) => { - setExists(Object.fromEntries(entries) as Partial>) - }) - }) - - const options = createMemo(() => { - return [ - { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, - ...apps() - .filter((app) => exists[app.id]) - .map((app) => ({ ...app, label: language.t(app.label) })), - ] as const - }) - const toggleTerminal = () => { const next = !view().terminal.opened() - view().terminal.toggle() + toggleSessionTerminal(view(), { openReviewPanel: isDesktopV2() && !!params.id }) if (!next) return const id = terminal.active() @@ -216,20 +64,6 @@ export function SessionHeader() { focusTerminalById(id) } - const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) - const [menu, setMenu] = createStore({ open: false }) - const [openRequest, setOpenRequest] = createStore({ - app: undefined as OpenApp | undefined, - }) - - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo( - () => - options().find((o) => o.id === prefs.app) ?? - options()[0] ?? - ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), - ) - const opening = createMemo(() => openRequest.app !== undefined) const tint = createMemo(() => messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) @@ -242,43 +76,6 @@ export function SessionHeader() { onReviewToggle: () => view().reviewPanel.toggle(), })) - const selectApp = (app: OpenApp) => { - if (!options().some((item) => item.id === app)) return - setPrefs("app", app) - } - - const openDir = (app: OpenApp) => { - if (opening() || !canOpen() || !platform.openPath) return - const directory = projectDirectory() - if (!directory) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - setOpenRequest("app", app) - platform - .openPath(directory, openWith) - .catch((err: unknown) => showRequestError(language, err)) - .finally(() => { - setOpenRequest("app", undefined) - }) - } - - const copyPath = () => { - const directory = projectDirectory() - if (!directory) return - navigator.clipboard - .writeText(directory) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("session.share.copy.copied"), - description: directory, - }) - }) - .catch((err: unknown) => showRequestError(language, err)) - } - const [centerMount, setCenterMount] = createSignal(null) const [rightMount, setRightMount] = createSignal(null) onMount(() => { @@ -328,13 +125,13 @@ export function SessionHeader() { diff --git a/packages/ui/src/v2/components/tooltip-v2.css b/packages/ui/src/v2/components/tooltip-v2.css index 19f17f1b617b..b85f5e0f3295 100644 --- a/packages/ui/src/v2/components/tooltip-v2.css +++ b/packages/ui/src/v2/components/tooltip-v2.css @@ -6,8 +6,8 @@ padding: 5px 6px; gap: 6px; - background: var(--background-bg-layer-01); - box-shadow: var(--elevation-floating); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-floating); border-radius: 4px; font-style: normal; @@ -15,7 +15,7 @@ font-size: 11px; line-height: 12px; letter-spacing: 0.05px; - color: var(--text-text-base); + color: var(--v2-text-text-base); font-variant-numeric: tabular-nums; font-variation-settings: "slnt" 0; diff --git a/packages/ui/src/v2/components/tooltip-v2.tsx b/packages/ui/src/v2/components/tooltip-v2.tsx index ac9c5d060853..07f5d0762bcc 100644 --- a/packages/ui/src/v2/components/tooltip-v2.tsx +++ b/packages/ui/src/v2/components/tooltip-v2.tsx @@ -29,6 +29,8 @@ export function TooltipV2(props: TooltipV2Props) { "forceOpen", "ignoreSafeArea", "value", + "openDelay", + "placement", ]) const close = () => setState("open", false) @@ -89,7 +91,8 @@ export function TooltipV2(props: TooltipV2Props) {