Compare commits

...

1 Commits

Author SHA1 Message Date
Adam
2264c93b6b wip(app): open button 2026-02-04 05:05:43 -06:00
13 changed files with 183 additions and 31 deletions

View File

@@ -319,7 +319,7 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
void platform.openLink(store.authorization.url).catch(() => undefined)
}
})
@@ -396,7 +396,7 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
void platform.openLink(store.authorization.url).catch(() => undefined)
}
const result = await globalSDK.client.provider.oauth

View File

@@ -10,7 +10,11 @@ export function Link(props: LinkProps) {
const [local, rest] = splitProps(props, ["href", "children"])
return (
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
<button
class="text-text-strong underline"
onClick={() => void platform.openLink(local.href).catch(() => undefined)}
{...rest}
>
{local.children}
</button>
)

View File

@@ -19,6 +19,7 @@ import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
import { StatusPopover } from "../status-popover"
import { SessionOpenMenu } from "./session-open-menu"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
@@ -117,7 +118,7 @@ export function SessionHeader() {
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
void platform.openLink(url).catch(() => undefined)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
@@ -150,6 +151,7 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
<SessionOpenMenu dir={projectDirectory()} />
<StatusPopover />
<Show when={showShare()}>
<div class="flex items-center">

View File

@@ -0,0 +1,110 @@
import { createMemo, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { FileTypeIcon } from "@opencode-ai/ui/file-type-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
export function SessionOpenMenu(props: { dir: string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
const enabled = createMemo(
() => platform.platform === "desktop" && platform.os === "macos" && server.isLocal() && !!props.dir,
)
const open = (app?: string) => {
if (!props.dir) return
void platform.openLink(props.dir, app).catch((error) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
}
const copy = () => {
if (!props.dir) return
navigator.clipboard
.writeText(props.dir)
.then(() => {
showToast({
variant: "success",
icon: "check",
title: language.t("session.header.copyPath.copied"),
})
})
.catch(() => {
showToast({
variant: "error",
title: language.t("session.header.copyPath.copyFailed"),
})
})
}
return (
<DropdownMenu modal={false}>
<DropdownMenu.Trigger
as={Button}
variant="ghost"
icon="folder"
class="rounded-sm h-[24px] py-1.5 pr-2 pl-2 gap-1.5 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
aria-label={language.t("session.header.open")}
>
<span class="text-12-regular text-text-strong">{language.t("session.header.open")}</span>
<Icon name="chevron-down" size="small" class="icon-base" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1 w-60">
<Show when={enabled()}>
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.Item onSelect={() => open("Visual Studio Code")}>
<FileTypeIcon id="Vscode" class="size-5" />
<DropdownMenu.ItemLabel>VS Code</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Cursor")}>
<FileTypeIcon id="Cursor" class="size-5" />
<DropdownMenu.ItemLabel>Cursor</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Finder")}>
<Icon name="folder" size="small" class="icon-base shrink-0" />
<DropdownMenu.ItemLabel>Finder</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Terminal")}>
<FileTypeIcon id="Console" class="size-5" />
<DropdownMenu.ItemLabel>Terminal</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("iTerm")}>
<FileTypeIcon id="Console" class="size-5" />
<DropdownMenu.ItemLabel>iTerm2</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Ghostty")}>
<FileTypeIcon id="Console" class="size-5" />
<DropdownMenu.ItemLabel>Ghostty</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Xcode")}>
<FileTypeIcon id="Swift" class="size-5" />
<DropdownMenu.ItemLabel>Xcode</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => open("Android Studio")}>
<FileTypeIcon id="Android" class="size-5" />
<DropdownMenu.ItemLabel>Android Studio</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</Show>
<DropdownMenu.Item onSelect={copy}>
<Icon name="copy" size="small" class="icon-base shrink-0" />
<DropdownMenu.ItemLabel>{language.t("session.header.copyPath")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}

View File

@@ -12,8 +12,8 @@ export type Platform = {
/** App version */
version?: string
/** Open a URL in the default browser */
openLink(url: string): void
/** Open a URL/path using the OS (optionally with a specific app) */
openLink(url: string, openWith?: string): Promise<void>
/** Restart the app */
restart(): Promise<void>

View File

@@ -28,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink(url: string) {
async openLink(url: string, _openWith?: string) {
window.open(url, "_blank")
},
back() {

View File

@@ -469,6 +469,11 @@ export const dict = {
"session.header.search.placeholder": "Search {{project}}",
"session.header.searchFiles": "Search files",
"session.header.open": "Open",
"session.header.openIn": "Open in",
"session.header.copyPath": "Copy Path",
"session.header.copyPath.copied": "Copied path",
"session.header.copyPath.copyFailed": "Failed to copy path to clipboard",
"status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations",

View File

@@ -269,14 +269,14 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
{language.t("error.page.report.prefix")}
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>{language.t("error.page.report.discord")}</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => void platform.openLink("https://opencode.ai/desktop-feedback").catch(() => undefined)}
>
<div>{language.t("error.page.report.discord")}</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
</div>
<Show when={platform.version}>
{(version) => (

View File

@@ -2995,7 +2995,7 @@ export default function Layout(props: ParentProps) {
icon="help"
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
onClick={() => void platform.openLink("https://opencode.ai/desktop-feedback").catch(() => undefined)}
aria-label={language.t("sidebar.help")}
/>
</Tooltip>

View File

@@ -6,6 +6,10 @@
"permissions": [
"core:default",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "/**", "app": true }]
},
"deep-link:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",

View File

@@ -1,19 +1,20 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core';
/** Commands */
export const commands = {
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
installCli: () => __TAURI_INVOKE<string>("install_cli"),
ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
}
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
installCli: () => __TAURI_INVOKE<string>("install_cli"),
ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
};
/* Types */
export type ServerReadyData = {
url: string
password: string | null
}
url: string,
password: string | null,
};

View File

@@ -4,7 +4,7 @@ import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { openPath, openUrl } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
@@ -94,8 +94,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
return result
},
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
openLink(url: string, openWith?: string) {
const isUrl = /^(https?:|mailto:|tel:|opencode:)/.test(url)
if (isUrl) return openUrl(url, openWith)
return openPath(url, openWith)
},
back() {
@@ -359,7 +361,7 @@ render(() => {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
void platform.openLink(link.href).catch(() => undefined)
}
}

View File

@@ -0,0 +1,24 @@
import type { Component, JSX } from "solid-js"
import { splitProps } from "solid-js"
import sprite from "./file-icons/sprite.svg"
import type { IconName } from "./file-icons/types"
export type FileTypeIconProps = JSX.SVGElementTags["svg"] & {
id: IconName
}
export const FileTypeIcon: Component<FileTypeIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList"])
return (
<svg
data-component="file-type-icon"
{...rest}
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
<use href={`${sprite}#${local.id}`} />
</svg>
)
}