Enable native dialogs across shells
This commit is contained in:
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -21,6 +22,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
const folders = () => recentFolders()
|
||||
@@ -78,7 +80,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
handleBrowse()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,9 +174,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function handleBrowse() {
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: "Select Workspace",
|
||||
defaultPath: fallbackPath,
|
||||
})
|
||||
if (selected) {
|
||||
handleFolderSelect(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsFolderBrowserOpen(true)
|
||||
}
|
||||
|
||||
@@ -313,7 +326,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
|
||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||
@@ -128,9 +131,19 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrowseBinary() {
|
||||
async function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: "Select OpenCode Binary",
|
||||
})
|
||||
if (selected) {
|
||||
setCustomPath(selected)
|
||||
void handleValidateAndAdd(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsBinaryBrowserOpen(true)
|
||||
}
|
||||
|
||||
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseBinary}
|
||||
onClick={() => void handleBrowseBinary()}
|
||||
disabled={props.disabled}
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
|
||||
39
packages/ui/src/lib/native/electron/functions.ts
Normal file
39
packages/ui/src/lib/native/electron/functions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
|
||||
interface ElectronDialogResult {
|
||||
canceled?: boolean
|
||||
paths?: string[]
|
||||
path?: string | null
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
openDialog?: (options: NativeDialogOptions) => Promise<ElectronDialogResult>
|
||||
}
|
||||
|
||||
function coerceFirstPath(result?: ElectronDialogResult | null): string | null {
|
||||
if (!result || result.canceled) {
|
||||
return null
|
||||
}
|
||||
const paths = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : []
|
||||
if (paths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return paths[0] ?? null
|
||||
}
|
||||
|
||||
export async function openElectronNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (!api?.openDialog) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await api.openDialog(options)
|
||||
return coerceFirstPath(result)
|
||||
} catch (error) {
|
||||
console.error("[native] electron dialog failed", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
37
packages/ui/src/lib/native/native-functions.ts
Normal file
37
packages/ui/src/lib/native/native-functions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import type { NativeDialogOptions } from "./types"
|
||||
import { openElectronNativeDialog } from "./electron/functions"
|
||||
import { openTauriNativeDialog } from "./tauri/functions"
|
||||
|
||||
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||
|
||||
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||
switch (runtimeEnv.host) {
|
||||
case "electron":
|
||||
return openElectronNativeDialog
|
||||
case "tauri":
|
||||
return openTauriNativeDialog
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsNativeDialogs(): boolean {
|
||||
return resolveNativeHandler() !== null
|
||||
}
|
||||
|
||||
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
const handler = resolveNativeHandler()
|
||||
if (!handler) {
|
||||
return null
|
||||
}
|
||||
return handler(options)
|
||||
}
|
||||
|
||||
export async function openNativeFolderDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
|
||||
return openNativeDialog({ mode: "directory", ...(options ?? {}) })
|
||||
}
|
||||
|
||||
export async function openNativeFileDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
|
||||
return openNativeDialog({ mode: "file", ...(options ?? {}) })
|
||||
}
|
||||
55
packages/ui/src/lib/native/tauri/functions.ts
Normal file
55
packages/ui/src/lib/native/tauri/functions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (
|
||||
options: {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: { name?: string; extensions: string[] }[]
|
||||
directory?: boolean
|
||||
multiple?: boolean
|
||||
},
|
||||
) => Promise<string | string[] | null>
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
dialog?: TauriDialogModule
|
||||
}
|
||||
|
||||
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
|
||||
const dialogApi = tauriBridge?.dialog
|
||||
if (!dialogApi?.open) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dialogApi.open({
|
||||
title: options.title,
|
||||
defaultPath: options.defaultPath,
|
||||
directory: options.mode === "directory",
|
||||
multiple: false,
|
||||
filters: options.filters?.map((filter) => ({
|
||||
name: filter.name,
|
||||
extensions: filter.extensions,
|
||||
})),
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response[0] ?? null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[native] tauri dialog failed", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
13
packages/ui/src/lib/native/types.ts
Normal file
13
packages/ui/src/lib/native/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type NativeDialogMode = "directory" | "file"
|
||||
|
||||
export interface NativeDialogFilter {
|
||||
name?: string
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
export interface NativeDialogOptions {
|
||||
mode: NativeDialogMode
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: NativeDialogFilter[]
|
||||
}
|
||||
@@ -14,6 +14,10 @@ declare global {
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
dialog?: {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
packages/ui/src/types/global.d.ts
vendored
39
packages/ui/src/types/global.d.ts
vendored
@@ -1,8 +1,47 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface ElectronDialogFilter {
|
||||
name?: string
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
interface ElectronDialogOptions {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: ElectronDialogFilter[]
|
||||
}
|
||||
|
||||
interface ElectronDialogResult {
|
||||
canceled?: boolean
|
||||
paths?: string[]
|
||||
path?: string | null
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
||||
onCliLog?: (callback: (data: unknown) => void) => () => void
|
||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
dialog?: TauriDialogModule
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user