From 179eab196e025ef427fae2dfa9edb3cd18a12981 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Mon, 17 Aug 2026 15:58:25 +0800 Subject: [PATCH 1/2] feat(windows): improve project opening experience --- rust/lithe-core/src/git/mod.rs | 27 ++- windows/tauri/src-tauri/src/host.rs | 17 +- .../components/project-name-menu.tsx | 2 +- .../project-open-destination.test.ts | 206 ++++++++++++++++ .../controllers/project-open-destination.ts | 135 +++++++++++ .../file-system/stores/file-system.store.ts | 91 ++++--- .../stores/recent-folders.store.ts | 64 +++-- .../file-system/types/interface.types.ts | 4 +- .../components/sidebar/main-sidebar.tsx | 41 +--- .../components/sidebar/sidebar-projects.tsx | 178 +------------- .../layout/utils/project-switcher.test.ts | 17 +- .../features/layout/utils/project-switcher.ts | 6 - .../onboarding/components/onboarding-view.tsx | 1 + .../components/macos-settings-panels.tsx | 24 +- .../settings/config/default-settings.ts | 2 +- .../lib/project-open-preference.test.ts | 44 ++++ .../settings/lib/project-open-preference.ts | 29 +++ .../features/settings/lib/settings-sync.ts | 4 +- .../features/settings/types/settings.types.ts | 2 +- .../window/components/new-project-content.tsx | 13 +- .../window/components/project-picker.tsx | 37 ++- .../window/components/title-bar/title-bar.tsx | 60 ++--- .../title-bar/title-project-menu.tsx | 228 ++++++++++++++++++ .../window/utils/project-picker-mode.test.ts | 25 ++ .../window/utils/project-picker-mode.ts | 12 + .../window/utils/title-bar-drag.test.ts | 60 +++++ .../features/window/utils/title-bar-drag.ts | 23 ++ .../utils/title-project-menu-model.test.ts | 82 +++++++ .../window/utils/title-project-menu-model.ts | 51 ++++ windows/tauri/src/i18n/locale.test.ts | 37 +++ windows/tauri/src/i18n/locale.ts | 26 ++ windows/tauri/src/ui/dialog.tsx | 89 +++++++ 32 files changed, 1284 insertions(+), 353 deletions(-) create mode 100644 windows/tauri/src/features/file-system/controllers/project-open-destination.test.ts create mode 100644 windows/tauri/src/features/file-system/controllers/project-open-destination.ts delete mode 100644 windows/tauri/src/features/layout/utils/project-switcher.ts create mode 100644 windows/tauri/src/features/settings/lib/project-open-preference.test.ts create mode 100644 windows/tauri/src/features/settings/lib/project-open-preference.ts create mode 100644 windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx create mode 100644 windows/tauri/src/features/window/utils/project-picker-mode.test.ts create mode 100644 windows/tauri/src/features/window/utils/project-picker-mode.ts create mode 100644 windows/tauri/src/features/window/utils/title-bar-drag.test.ts create mode 100644 windows/tauri/src/features/window/utils/title-bar-drag.ts create mode 100644 windows/tauri/src/features/window/utils/title-project-menu-model.test.ts create mode 100644 windows/tauri/src/features/window/utils/title-project-menu-model.ts diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 88a9a0d7..a17e2808 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -12,6 +12,8 @@ use crate::protocol::{ use serde::{Deserialize, Serialize}; use std::io::Read; use std::io::Write; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; @@ -526,7 +528,7 @@ fn execute_git_with_options( disable_optional_locks: bool, ) -> Result { crate::protocol::cancellation::check()?; - let mut process = Command::new("git"); + let mut process = git_process(); process.args(arguments).current_dir(root); if disable_optional_locks { process.env("GIT_OPTIONAL_LOCKS", "0"); @@ -599,6 +601,21 @@ fn execute_git_with_options( }) } +fn git_process() -> Command { + let mut process = Command::new("git"); + #[cfg(target_os = "windows")] + process.creation_flags(git_process_creation_flags()); + process +} + +#[cfg(target_os = "windows")] +fn git_process_creation_flags() -> u32 { + // Git runs as an IDE background task; attaching a console can briefly open + // the user's default terminal whenever status or repository data refreshes. + const CREATE_NO_WINDOW: u32 = 0x08000000; + CREATE_NO_WINDOW +} + /// Builds a structured working-tree, staged, untracked, or commit diff. pub fn diff(request: GitDiffRequest) -> Result { if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path)) @@ -2752,7 +2769,7 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec { texts .iter() diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index bb1a64f5..78256f69 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -247,7 +247,7 @@ fn copy_path(source: &std::path::Path, destination: &std::path::Path) -> Result< } #[tauri::command] -pub fn create_app_window(app: AppHandle, request: Option) -> Result { +pub async fn create_app_window(app: AppHandle, request: Option) -> Result { let label = format!("workspace-{}", WINDOW_ID.fetch_add(1, Ordering::Relaxed)); let mut query = url::form_urlencoded::Serializer::new(String::new()); if let Some(request) = request.and_then(|value| value.as_object().cloned()) { @@ -298,10 +298,23 @@ pub fn create_app_window(app: AppHandle, request: Option) -> Result(_command: F) + where + F: Fn(tauri::AppHandle, Option) -> Fut, + Fut: Future>, + { + } + + #[test] + fn creates_app_windows_outside_the_synchronous_ipc_handler() { + assert_async_window_command(create_app_window); + } + #[test] fn parses_path_and_web_cli_arguments() { let payloads = cli_payloads([ diff --git a/windows/tauri/src/features/file-system/components/project-name-menu.tsx b/windows/tauri/src/features/file-system/components/project-name-menu.tsx index 72e38f41..7db35339 100644 --- a/windows/tauri/src/features/file-system/components/project-name-menu.tsx +++ b/windows/tauri/src/features/file-system/components/project-name-menu.tsx @@ -27,7 +27,7 @@ export const ProjectNameMenu = () => { id: "open-folder", label: "Open Folder in New Tab", icon: , - onClick: () => handleOpenFolder(), + onClick: () => handleOpenFolder({ destination: "this-window" }), }, { id: "add-folder-to-workspace", diff --git a/windows/tauri/src/features/file-system/controllers/project-open-destination.test.ts b/windows/tauri/src/features/file-system/controllers/project-open-destination.test.ts new file mode 100644 index 00000000..34c19b16 --- /dev/null +++ b/windows/tauri/src/features/file-system/controllers/project-open-destination.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { defaultSettings } from "@/features/settings/config/default-settings"; +import { + chooseProjectOpenDestination, + executeProjectOpenDecision, + hasOpenProjectWorkspace, + type ProjectOpenDestination, + type ProjectOpenDestinationServices, + type ProjectOpenPromptResult, +} from "./project-open-destination"; + +function createServices(options?: { + askWhereToOpenProjects?: boolean; + openFoldersInNewWindow?: boolean; + promptResult?: ProjectOpenPromptResult | null; + language?: "en-US" | "zh-CN"; +}) { + const promptRequests: Array<{ projectName: string; language: "en-US" | "zh-CN" }> = []; + const updates: Array<[string, boolean]> = []; + + const services: ProjectOpenDestinationServices = { + getSettings: () => ({ + askWhereToOpenProjects: options?.askWhereToOpenProjects ?? true, + openFoldersInNewWindow: options?.openFoldersInNewWindow ?? true, + displayLanguage: options?.language ?? "en-US", + }), + prompt: async (request) => { + promptRequests.push(request); + return options?.promptResult ?? null; + }, + updateSetting: async (key, value) => { + updates.push([key, value]); + }, + }; + + return { services, promptRequests, updates }; +} + +describe("project open destination", () => { + test("asks where to open projects by default", () => { + expect(defaultSettings.askWhereToOpenProjects).toBe(true); + }); + + test("opens directly in this window when no workspace is open", async () => { + const { services, promptRequests } = createServices(); + + const decision = await chooseProjectOpenDestination( + { projectName: "Lithe", hasOpenWorkspace: false }, + services, + ); + + expect(decision).toEqual({ destination: "this-window", rememberAfterOpen: false }); + expect(promptRequests).toEqual([]); + }); + + test.each([ + [true, "new-window"], + [false, "this-window"], + ] as const)("uses the remembered destination when prompting is disabled", async (openNew, expected) => { + const { services, promptRequests } = createServices({ + askWhereToOpenProjects: false, + openFoldersInNewWindow: openNew, + }); + + const decision = await chooseProjectOpenDestination( + { projectName: "Lithe", hasOpenWorkspace: true }, + services, + ); + + expect(decision).toEqual({ destination: expected, rememberAfterOpen: false }); + expect(promptRequests).toEqual([]); + }); + + test("returns an unchecked selection without changing settings", async () => { + const selected: ProjectOpenDestination = "new-window"; + const { services, promptRequests, updates } = createServices({ + language: "zh-CN", + promptResult: { destination: selected, doNotAskAgain: false }, + }); + + const decision = await chooseProjectOpenDestination( + { projectName: "Lithe", hasOpenWorkspace: true }, + services, + ); + + expect(decision).toEqual({ destination: selected, rememberAfterOpen: false }); + expect(promptRequests).toEqual([{ projectName: "Lithe", language: "zh-CN" }]); + expect(updates).toEqual([]); + }); + + test.each(["new-window", "this-window"] as const)( + "defers a checked destination until the project opens", + async (selected) => { + const { services, updates } = createServices({ + promptResult: { destination: selected, doNotAskAgain: true }, + }); + + const decision = await chooseProjectOpenDestination( + { projectName: "Lithe", hasOpenWorkspace: true }, + services, + ); + + expect(decision).toEqual({ destination: selected, rememberAfterOpen: true }); + expect(updates).toEqual([]); + }, + ); + + test("cancels without opening or changing settings", async () => { + const { services, updates } = createServices({ promptResult: null }); + + const decision = await chooseProjectOpenDestination( + { projectName: "Lithe", hasOpenWorkspace: true }, + services, + ); + + expect(decision).toBeNull(); + expect(updates).toEqual([]); + }); + + test("uses an explicit destination without prompting or remembering", async () => { + const { services, promptRequests, updates } = createServices({ + promptResult: { destination: "new-window", doNotAskAgain: true }, + }); + + const decision = await chooseProjectOpenDestination( + { + projectName: "Lithe", + hasOpenWorkspace: true, + explicitDestination: "this-window", + }, + services, + ); + + expect(decision).toEqual({ destination: "this-window", rememberAfterOpen: false }); + expect(promptRequests).toEqual([]); + expect(updates).toEqual([]); + }); + + test("persists a checked destination only after a successful open", async () => { + const { services, updates } = createServices(); + const events: string[] = []; + const recordingServices: ProjectOpenDestinationServices = { + ...services, + updateSetting: async (key, value) => { + events.push(`setting:${key}:${value}`); + updates.push([key, value]); + }, + }; + + const opened = await executeProjectOpenDecision( + { destination: "new-window", rememberAfterOpen: true }, + async (destination) => { + events.push(`open:${destination}`); + return true; + }, + recordingServices, + ); + + expect(opened).toBe(true); + expect(events).toEqual([ + "open:new-window", + "setting:openFoldersInNewWindow:true", + "setting:askWhereToOpenProjects:false", + ]); + }); + + test.each([false, "throw"] as const)( + "does not persist a checked destination when opening fails", + async (failureMode) => { + const { services, updates } = createServices(); + const execute = () => + executeProjectOpenDecision( + { destination: "this-window", rememberAfterOpen: true }, + async () => { + if (failureMode === "throw") throw new Error("open failed"); + return false; + }, + services, + ); + + if (failureMode === "throw") { + await expect(execute()).rejects.toThrow("open failed"); + } else { + expect(await execute()).toBe(false); + } + expect(updates).toEqual([]); + }, + ); + + test("treats an existing project tab as an open workspace during initialization", () => { + expect( + hasOpenProjectWorkspace({ + rootFolderPath: undefined, + fileCount: 0, + projectTabCount: 1, + }), + ).toBe(true); + expect( + hasOpenProjectWorkspace({ + rootFolderPath: undefined, + fileCount: 0, + projectTabCount: 0, + }), + ).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/file-system/controllers/project-open-destination.ts b/windows/tauri/src/features/file-system/controllers/project-open-destination.ts new file mode 100644 index 00000000..31673df5 --- /dev/null +++ b/windows/tauri/src/features/file-system/controllers/project-open-destination.ts @@ -0,0 +1,135 @@ +import type { DisplayLanguage } from "@/i18n/locale"; +import { createTranslator } from "@/i18n/locale"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { showChoiceDialogWithCheckbox } from "@/ui/dialog"; + +export type ProjectOpenDestination = "new-window" | "this-window"; + +export interface ProjectOpenPromptResult { + destination: ProjectOpenDestination; + doNotAskAgain: boolean; +} + +export interface ProjectOpenDecision { + destination: ProjectOpenDestination; + rememberAfterOpen: boolean; +} + +export interface ProjectOpenDestinationRequest { + projectName: string; + hasOpenWorkspace: boolean; + explicitDestination?: ProjectOpenDestination; +} + +export interface ProjectWorkspaceState { + rootFolderPath: string | undefined; + fileCount: number; + projectTabCount: number; +} + +type ProjectOpenSettingKey = "askWhereToOpenProjects" | "openFoldersInNewWindow"; + +export interface ProjectOpenDestinationServices { + getSettings: () => { + askWhereToOpenProjects: boolean; + openFoldersInNewWindow: boolean; + displayLanguage: DisplayLanguage; + }; + prompt: (request: { + projectName: string; + language: DisplayLanguage; + }) => Promise; + updateSetting: (key: ProjectOpenSettingKey, value: boolean) => Promise; +} + +const defaultServices: ProjectOpenDestinationServices = { + getSettings: () => { + const { settings } = useSettingsStore.getState(); + return { + askWhereToOpenProjects: settings.askWhereToOpenProjects, + openFoldersInNewWindow: settings.openFoldersInNewWindow, + displayLanguage: settings.displayLanguage, + }; + }, + prompt: async ({ projectName, language }) => { + const t = createTranslator(language); + const result = await showChoiceDialogWithCheckbox( + t("projectOpen.where", { project: projectName }), + { + title: t("projectOpen.title"), + checkboxLabel: t("projectOpen.doNotAskAgain"), + cancelLabel: t("projectOpen.cancel"), + choices: [ + { value: "new-window", label: t("projectOpen.newWindow") }, + { value: "this-window", label: t("projectOpen.thisWindow"), variant: "accent" }, + ], + }, + ); + + return result + ? { destination: result.value, doNotAskAgain: result.checked } + : null; + }, + updateSetting: async (key, value) => { + await useSettingsStore.getState().actions.updateSetting(key, value); + }, +}; + +export async function chooseProjectOpenDestination( + request: ProjectOpenDestinationRequest, + services: ProjectOpenDestinationServices = defaultServices, +): Promise { + if (request.explicitDestination) { + return { destination: request.explicitDestination, rememberAfterOpen: false }; + } + + if (!request.hasOpenWorkspace) { + return { destination: "this-window", rememberAfterOpen: false }; + } + + const settings = services.getSettings(); + if (!settings.askWhereToOpenProjects) { + return { + destination: settings.openFoldersInNewWindow ? "new-window" : "this-window", + rememberAfterOpen: false, + }; + } + + const result = await services.prompt({ + projectName: request.projectName, + language: settings.displayLanguage, + }); + if (!result) { + return null; + } + + return { + destination: result.destination, + rememberAfterOpen: result.doNotAskAgain, + }; +} + +export async function executeProjectOpenDecision( + decision: ProjectOpenDecision, + open: (destination: ProjectOpenDestination) => Promise, + services: ProjectOpenDestinationServices = defaultServices, +): Promise { + const opened = await open(decision.destination); + if (!opened) { + return false; + } + + if (decision.rememberAfterOpen) { + await services.updateSetting( + "openFoldersInNewWindow", + decision.destination === "new-window", + ); + await services.updateSetting("askWhereToOpenProjects", false); + } + + return true; +} + +export function hasOpenProjectWorkspace(state: ProjectWorkspaceState): boolean { + return !!state.rootFolderPath || state.fileCount > 0 || state.projectTabCount > 0; +} diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index e6935738..c9161fac 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -75,6 +75,11 @@ import { readDirectoryContents, readFileContent, } from "../controllers/file-operations"; +import { + chooseProjectOpenDestination, + executeProjectOpenDecision, + hasOpenProjectWorkspace, +} from "../controllers/project-open-destination"; import { addFileToTree, findFileInTree, @@ -741,49 +746,61 @@ const createFileSystemStore = (workspaceId: string): StoreApi => { + handleOpenFolder: async (options): Promise => { const selected = await openFolder(); if (!selected) return false; - const { settings } = useSettingsStore.getState(); - const hasOpenWorkspace = - !!get().rootFolderPath || useWorkspaceTabsStore.getState().projectTabs.length > 0; + const currentState = get(); + const hasOpenWorkspace = hasOpenProjectWorkspace({ + rootFolderPath: currentState.rootFolderPath, + fileCount: currentState.files.length, + projectTabCount: useWorkspaceTabsStore.getState().projectTabs.length, + }); + const decision = await chooseProjectOpenDestination({ + projectName: getFolderName(selected), + hasOpenWorkspace, + explicitDestination: options?.destination, + }); - if (settings.openFoldersInNewWindow && hasOpenWorkspace) { - await createAppWindow({ - path: selected, - isDirectory: true, - }); - return true; - } + if (!decision) return false; - const { openWorkspaceRuntime } = - await import("@/features/workspace/services/workspace-lifecycle"); - return await openWorkspaceRuntime({ - descriptor: { path: selected, name: getFolderName(selected) }, - persistCurrent: () => get().persistActiveProjectSession(), - initialize: (workspaceId): Promise => - getScopedFileSystemStore(workspaceId).getState().initializeLocalWorkspace({ - workspaceId, + return await executeProjectOpenDecision(decision, async (destination) => { + if (destination === "new-window") { + await createAppWindow({ path: selected, - traceLabel: "handleOpenFolder", - treeState: "expand-root", - restoreUiState: false, - }), - resume: async (workspaceId) => { - const targetStore = getScopedFileSystemStore(workspaceId).getState(); - targetStore.resumeWorkspaceSession(); - initializeLocalWorkspaceInBackground( - workspaceId, - selected, - () => targetStore, - "Failed to resume workspace services:", - { - deferWatcher: true, - preserveGitStatus: true, - }, - ); - }, + isDirectory: true, + }); + return true; + } + + const { openWorkspaceRuntime } = + await import("@/features/workspace/services/workspace-lifecycle"); + return await openWorkspaceRuntime({ + descriptor: { path: selected, name: getFolderName(selected) }, + persistCurrent: () => get().persistActiveProjectSession(), + initialize: (workspaceId): Promise => + getScopedFileSystemStore(workspaceId).getState().initializeLocalWorkspace({ + workspaceId, + path: selected, + traceLabel: "handleOpenFolder", + treeState: "expand-root", + restoreUiState: false, + }), + resume: async (workspaceId) => { + const targetStore = getScopedFileSystemStore(workspaceId).getState(); + targetStore.resumeWorkspaceSession(); + initializeLocalWorkspaceInBackground( + workspaceId, + selected, + () => targetStore, + "Failed to resume workspace services:", + { + deferWatcher: true, + preserveGitStatus: true, + }, + ); + }, + }); }); }, diff --git a/windows/tauri/src/features/file-system/stores/recent-folders.store.ts b/windows/tauri/src/features/file-system/stores/recent-folders.store.ts index 25ffa8f5..4bb722c4 100644 --- a/windows/tauri/src/features/file-system/stores/recent-folders.store.ts +++ b/windows/tauri/src/features/file-system/stores/recent-folders.store.ts @@ -1,11 +1,17 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; -import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { createAppWindow } from "@/features/window/utils/create-app-window"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { getFolderName } from "@/utils/path-helpers"; import { createSelectors } from "@/utils/zustand-selectors"; import { createSafeJSONStorage } from "@/utils/zustand-storage"; import type { RecentFolder, RecentFolderMetadata } from "../types/recent-folders.types"; +import { + chooseProjectOpenDestination, + executeProjectOpenDecision, + hasOpenProjectWorkspace, +} from "../controllers/project-open-destination"; import { removeMissingRecentFolders, toggleRecentFolderPinned, @@ -84,10 +90,13 @@ const useRecentFoldersStoreBase = create()( try { const { getSymlinkInfo } = await import("../controllers/platform"); const { useFileSystemStore } = await import("../stores/file-system.store"); - const { handleOpenFolderByPath, rootFolderPath } = useFileSystemStore.getState(); - const { settings } = useSettingsStore.getState(); - const hasOpenWorkspace = - !!rootFolderPath || useFileSystemStore.getState().files.length > 0; + const fileSystemState = useFileSystemStore.getState(); + const { handleOpenFolderByPath, rootFolderPath } = fileSystemState; + const hasOpenWorkspace = hasOpenProjectWorkspace({ + rootFolderPath, + fileCount: fileSystemState.files.length, + projectTabCount: useWorkspaceTabsStore.getState().projectTabs.length, + }); try { const pathInfo = await getSymlinkInfo(folderPath); @@ -105,25 +114,34 @@ const useRecentFoldersStoreBase = create()( return; } - if (settings.openFoldersInNewWindow && hasOpenWorkspace) { - await createAppWindow({ - path: folderPath, - isDirectory: true, - }); - get().actions.addToRecents(folderPath, { - missing: false, - openInNewWindow: true, - }); - return; - } + const decision = await chooseProjectOpenDestination({ + projectName: getFolderName(folderPath), + hasOpenWorkspace, + }); + if (!decision) return; + + await executeProjectOpenDecision(decision, async (destination) => { + if (destination === "new-window") { + await createAppWindow({ + path: folderPath, + isDirectory: true, + }); + get().actions.addToRecents(folderPath, { + missing: false, + openInNewWindow: true, + }); + return true; + } - const opened = await handleOpenFolderByPath(folderPath); - if (opened) { - get().actions.addToRecents(folderPath, { - missing: false, - openInNewWindow: false, - }); - } + const opened = await handleOpenFolderByPath(folderPath); + if (opened) { + get().actions.addToRecents(folderPath, { + missing: false, + openInNewWindow: false, + }); + } + return opened; + }); } catch (error) { console.error("Error opening recent folder:", error); } diff --git a/windows/tauri/src/features/file-system/types/interface.types.ts b/windows/tauri/src/features/file-system/types/interface.types.ts index ea6f36ab..d2fb28bc 100644 --- a/windows/tauri/src/features/file-system/types/interface.types.ts +++ b/windows/tauri/src/features/file-system/types/interface.types.ts @@ -25,7 +25,9 @@ export interface FsState { export interface FsActions { // Folder operations - handleOpenFolder: () => Promise; + handleOpenFolder: (options?: { + destination?: "new-window" | "this-window"; + }) => Promise; handleOpenFolderByPath: (path: string) => Promise; addFolderToWorkspace: (path?: string) => Promise; removeFolderFromWorkspace: (path: string) => Promise; diff --git a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx index d7b18937..ddd22e40 100644 --- a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx @@ -15,10 +15,7 @@ import { FileExplorerPane } from "@/features/file-explorer/components/file-explo import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import GitView from "@/features/git/components/git-view"; import { SidebarPaneSelector } from "@/features/layout/components/sidebar/sidebar-pane-selector"; -import { - SidebarProjectDots, - SidebarProjectSwitcher, -} from "@/features/layout/components/sidebar/sidebar-projects"; +import { SidebarProjectDots } from "@/features/layout/components/sidebar/sidebar-projects"; import { useSidebarPaneController } from "@/features/layout/hooks/use-sidebar-pane-controller"; import { getAdjacentProjectIndex, @@ -26,7 +23,6 @@ import { getProjectSnapDuration, getProjectSwipeBounds, } from "@/features/layout/utils/project-carousel"; -import { shouldShowProjectSwitcher } from "@/features/layout/utils/project-switcher"; import type { SidebarView } from "@/features/layout/utils/sidebar-pane-utils"; import { RunIcon } from "@/features/run/components/run-icon"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; @@ -129,17 +125,17 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa ); }); const configuredActivityRailWidth = useSettingsStore((state) => state.settings.activityRailWidth); + const askWhereToOpenProjects = useSettingsStore( + (state) => state.settings.askWhereToOpenProjects, + ); const openFoldersInNewWindow = useSettingsStore((state) => state.settings.openFoldersInNewWindow); const hiddenSidebarActivityItems = useSettingsStore( (state) => state.settings.hiddenSidebarActivityItems, ); - const showActivityRailProjectSwitcher = useSettingsStore( - (state) => state.settings.showActivityRailProjectSwitcher, - ); const showActivityRailProjectIcons = useSettingsStore( (state) => state.settings.showActivityRailProjectIcons, ); - const projectCarouselEnabled = !openFoldersInNewWindow; + const projectCarouselEnabled = askWhereToOpenProjects || !openFoldersInNewWindow; const updateSetting = useSettingsStore((state) => state.actions.updateSetting); const [activityRailWidth, setActivityRailWidth] = useState(() => clampActivityRailWidth(configuredActivityRailWidth || DEFAULT_ACTIVITY_RAIL_WIDTH), @@ -175,10 +171,6 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa projectCarouselDirection < 0 && carouselTargetProject ? carouselTargetProject : previousProject; const renderedNextProject = projectCarouselDirection > 0 && carouselTargetProject ? carouselTargetProject : nextProject; - const projectSwitcherVisible = shouldShowProjectSwitcher( - showActivityRailProjectSwitcher, - openFoldersInNewWindow, - ); const switchToProject = useFileSystemStore((state) => state.switchToProject); const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject); const handleSidebarViewChange = (view: typeof activeSidebarView) => { @@ -265,13 +257,10 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa ); const hasHiddenActivityRailItems = - hiddenSidebarActivityItems.length > 0 || - !showActivityRailProjectSwitcher || - !showActivityRailProjectIcons; + hiddenSidebarActivityItems.length > 0 || !showActivityRailProjectIcons; const showAllActivityRailItems = useCallback(() => { void updateSetting("hiddenSidebarActivityItems", []); - void updateSetting("showActivityRailProjectSwitcher", true); void updateSetting("showActivityRailProjectIcons", true); }, [updateSetting]); @@ -660,15 +649,6 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa > {isBoundaryPanel ? null : ( <> - {projectSwitcherVisible ? ( - - ) : null} {isLoadingProject ? (
Show in Activity Sidebar - - void updateSetting("showActivityRailProjectSwitcher", checked) - } - > - - Project Switcher - {activityRailVisibilityItems.map((item) => ( - ); - } - - const Icon = isRemote ? RemoteIcon : projectPath ? FolderIcon : PlusIcon; - - return ; -} - -export function SidebarProjectSwitcher({ - expanded, - project, - projects, - isSwitchingProject, - onSelectProject, -}: { - expanded: boolean; - project?: ProjectTab; - projects: ProjectTab[]; - isSwitchingProject: boolean; - onSelectProject: (projectId: string) => void; -}) { - const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); - const handleOpenFolder = useFileSystemStore((state) => state.handleOpenFolder); - const [detectedIconPath, setDetectedIconPath] = useState(); - const [iconPickerProject, setIconPickerProject] = useState(null); - const displayProject = project; - const projectName = displayProject?.name || getProjectNameFromPath(rootFolderPath); - const projectPath = displayProject?.path || rootFolderPath; - const customIcon = displayProject?.customIcon; - const isRemote = isRemoteProjectPath(projectPath); - const displayIconPath = customIcon ?? detectedIconPath; - const displayProjectKey = displayProject?.id ?? projectPath; - - useEffect(() => { - setDetectedIconPath(undefined); - - if (!displayProject || customIcon || isRemote || !projectPath) return; - - let cancelled = false; - - findBestProjectIcon(projectPath).then((iconFile) => { - if (!cancelled) { - setDetectedIconPath(iconFile?.path); - } - }); - - return () => { - cancelled = true; - }; - }, [displayProject, displayProjectKey, customIcon, isRemote, projectPath]); - - const canChangeIcon = !!displayProject && !!projectPath && !isRemote; - const projectGlyph = ; - - return ( - <> - - { - if (!canChangeIcon || !displayProject) return; - event.stopPropagation(); - setIconPickerProject(displayProject); - }} - onKeyDown={(event) => { - if (!canChangeIcon || !displayProject) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - setIconPickerProject(displayProject); - }} - > - {projectGlyph} - - ) : ( - projectGlyph - ) - } - iconOnly={!expanded} - trailing={expanded ? : undefined} - aria-label="Switch project" - title={expanded ? undefined : projectName} - > - {projectName} - - } - /> - - {projects.length > 0 ? ( - <> - - {projects.map((availableProject) => ( - - - {availableProject.name} - - ))} - - - - ) : null} - void handleOpenFolder()}> - - Open project… - - - - {iconPickerProject ? ( - setIconPickerProject(null)} - projectId={iconPickerProject.id} - projectPath={iconPickerProject.path} - /> - ) : null} - - ); -} - export function SidebarProjectDots({ projects, activeProjectId, diff --git a/windows/tauri/src/features/layout/utils/project-switcher.test.ts b/windows/tauri/src/features/layout/utils/project-switcher.test.ts index 9ab16c5a..35269ea6 100644 --- a/windows/tauri/src/features/layout/utils/project-switcher.test.ts +++ b/windows/tauri/src/features/layout/utils/project-switcher.test.ts @@ -1,17 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { shouldShowProjectSwitcher } from "./project-switcher"; +import { defaultSettings } from "@/features/settings/config/default-settings"; -describe("project switcher visibility", () => { - test("hides the activity-bar switcher when projects open in new windows", () => { - expect(shouldShowProjectSwitcher(true, true)).toBe(false); - }); - - test("shows the activity-bar switcher when in-window switching is enabled", () => { - expect(shouldShowProjectSwitcher(true, false)).toBe(true); - }); - - test("respects the user's project switcher visibility setting", () => { - expect(shouldShowProjectSwitcher(false, false)).toBe(false); - expect(shouldShowProjectSwitcher(false, true)).toBe(false); +describe("activity rail project switcher", () => { + test("does not expose the removed project switcher setting", () => { + expect("showActivityRailProjectSwitcher" in defaultSettings).toBe(false); }); }); diff --git a/windows/tauri/src/features/layout/utils/project-switcher.ts b/windows/tauri/src/features/layout/utils/project-switcher.ts deleted file mode 100644 index 71e948b2..00000000 --- a/windows/tauri/src/features/layout/utils/project-switcher.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function shouldShowProjectSwitcher( - showProjectSwitcher: boolean, - openFoldersInNewWindow: boolean, -) { - return showProjectSwitcher && !openFoldersInNewWindow; -} diff --git a/windows/tauri/src/features/onboarding/components/onboarding-view.tsx b/windows/tauri/src/features/onboarding/components/onboarding-view.tsx index 23d252de..06afadb2 100644 --- a/windows/tauri/src/features/onboarding/components/onboarding-view.tsx +++ b/windows/tauri/src/features/onboarding/components/onboarding-view.tsx @@ -98,6 +98,7 @@ export default function OnboardingView({ bufferId, context }: OnboardingViewProp updateSetting("telemetry", telemetry), updateSetting("vimMode", vimMode), updateSetting("openFoldersInNewWindow", openFoldersInNewWindow), + updateSetting("askWhereToOpenProjects", false), updateSetting("keybindingPreset", keybindingPreset), ]); }; diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx index 19f53d7e..2ae283ff 100644 --- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx @@ -2,6 +2,11 @@ import { getVersion } from "@tauri-apps/api/app"; import { useEffect, useState, type ReactNode } from "react"; import { useUpdater } from "@/features/settings/hooks/use-updater"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { + getProjectOpenPreference, + getProjectOpenPreferencePatch, + type ProjectOpenPreference, +} from "@/features/settings/lib/project-open-preference"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; @@ -57,9 +62,7 @@ function GeneralPanel() { const { t } = useTranslation(); const settings = useSettingsStore((state) => state.settings); const updateSetting = useSettingsStore((state) => state.actions.updateSetting); - const [projectPlacement, setProjectPlacement] = useState( - settings.openFoldersInNewWindow ? "new-window" : "same-window", - ); + const projectPlacement = getProjectOpenPreference(settings); const [gitPolicy, setGitPolicy] = useState("ask"); const [directoryPatterns, setDirectoryPatterns] = useState( settings.hiddenDirectoryPatterns.join("\n"), @@ -141,15 +144,20 @@ function GeneralPanel() { className={`${controlClassName} w-40`} value={projectPlacement} onChange={(event) => { - const value = event.target.value; - setProjectPlacement(value); - if (value !== "ask") { - void updateSetting("openFoldersInNewWindow", value === "new-window"); + const patch = getProjectOpenPreferencePatch( + event.target.value as ProjectOpenPreference, + ); + if (patch.openFoldersInNewWindow !== undefined) { + void updateSetting("openFoldersInNewWindow", patch.openFoldersInNewWindow); } + void updateSetting( + "askWhereToOpenProjects", + patch.askWhereToOpenProjects ?? true, + ); }} > - + diff --git a/windows/tauri/src/features/settings/config/default-settings.ts b/windows/tauri/src/features/settings/config/default-settings.ts index 7c98264f..9583138c 100644 --- a/windows/tauri/src/features/settings/config/default-settings.ts +++ b/windows/tauri/src/features/settings/config/default-settings.ts @@ -84,6 +84,7 @@ export const defaultSettings: Settings = { hiddenSidebarActivityItems: [], footerLeadingItemsOrder: [...FOOTER_LEADING_ITEM_IDS], footerTrailingItemsOrder: [...FOOTER_TRAILING_ITEM_IDS], + askWhereToOpenProjects: true, openFoldersInNewWindow: true, // AI aiProviderId: DEFAULT_AI_PROVIDER_ID, @@ -105,7 +106,6 @@ export const defaultSettings: Settings = { // Layout activityRailExpanded: false, activityRailWidth: 180, - showActivityRailProjectSwitcher: true, showActivityRailAgentHistory: false, showActivityRailTerminals: false, showActivityRailWorktrees: false, diff --git a/windows/tauri/src/features/settings/lib/project-open-preference.test.ts b/windows/tauri/src/features/settings/lib/project-open-preference.test.ts new file mode 100644 index 00000000..b1751b9d --- /dev/null +++ b/windows/tauri/src/features/settings/lib/project-open-preference.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { + getProjectOpenPreference, + getProjectOpenPreferencePatch, +} from "./project-open-preference"; + +describe("project open preference", () => { + test("maps persisted settings to all three visible modes", () => { + expect( + getProjectOpenPreference({ + askWhereToOpenProjects: true, + openFoldersInNewWindow: true, + }), + ).toBe("ask"); + expect( + getProjectOpenPreference({ + askWhereToOpenProjects: false, + openFoldersInNewWindow: false, + }), + ).toBe("this-window"); + expect( + getProjectOpenPreference({ + askWhereToOpenProjects: false, + openFoldersInNewWindow: true, + }), + ).toBe("new-window"); + }); + + test("keeps the remembered destination when ask mode is selected", () => { + expect(getProjectOpenPreferencePatch("ask")).toEqual({ + askWhereToOpenProjects: true, + }); + }); + + test.each([ + ["this-window", false], + ["new-window", true], + ] as const)("disables asking for an explicit destination", (preference, openNew) => { + expect(getProjectOpenPreferencePatch(preference)).toEqual({ + askWhereToOpenProjects: false, + openFoldersInNewWindow: openNew, + }); + }); +}); diff --git a/windows/tauri/src/features/settings/lib/project-open-preference.ts b/windows/tauri/src/features/settings/lib/project-open-preference.ts new file mode 100644 index 00000000..8987f16e --- /dev/null +++ b/windows/tauri/src/features/settings/lib/project-open-preference.ts @@ -0,0 +1,29 @@ +export type ProjectOpenPreference = "ask" | "this-window" | "new-window"; + +interface ProjectOpenPreferenceSettings { + askWhereToOpenProjects: boolean; + openFoldersInNewWindow: boolean; +} + +export function getProjectOpenPreference( + settings: ProjectOpenPreferenceSettings, +): ProjectOpenPreference { + if (settings.askWhereToOpenProjects) { + return "ask"; + } + + return settings.openFoldersInNewWindow ? "new-window" : "this-window"; +} + +export function getProjectOpenPreferencePatch( + preference: ProjectOpenPreference, +): Partial { + if (preference === "ask") { + return { askWhereToOpenProjects: true }; + } + + return { + askWhereToOpenProjects: false, + openFoldersInNewWindow: preference === "new-window", + }; +} diff --git a/windows/tauri/src/features/settings/lib/settings-sync.ts b/windows/tauri/src/features/settings/lib/settings-sync.ts index cc6c277b..a7264048 100644 --- a/windows/tauri/src/features/settings/lib/settings-sync.ts +++ b/windows/tauri/src/features/settings/lib/settings-sync.ts @@ -67,6 +67,7 @@ type SyncableSettingsKey = | "hiddenSidebarActivityItems" | "footerLeadingItemsOrder" | "footerTrailingItemsOrder" + | "askWhereToOpenProjects" | "openFoldersInNewWindow" | "aiProviderId" | "aiModelId" @@ -86,7 +87,6 @@ type SyncableSettingsKey = | "ollamaBaseUrl" | "activityRailExpanded" | "activityRailWidth" - | "showActivityRailProjectSwitcher" | "showActivityRailAgentHistory" | "showActivityRailTerminals" | "showActivityRailWorktrees" @@ -193,6 +193,7 @@ const SYNCABLE_SETTINGS_KEYS: SyncableSettingsKey[] = [ "hiddenSidebarActivityItems", "footerLeadingItemsOrder", "footerTrailingItemsOrder", + "askWhereToOpenProjects", "openFoldersInNewWindow", "aiProviderId", "aiModelId", @@ -212,7 +213,6 @@ const SYNCABLE_SETTINGS_KEYS: SyncableSettingsKey[] = [ "ollamaBaseUrl", "activityRailExpanded", "activityRailWidth", - "showActivityRailProjectSwitcher", "showActivityRailAgentHistory", "showActivityRailTerminals", "showActivityRailWorktrees", diff --git a/windows/tauri/src/features/settings/types/settings.types.ts b/windows/tauri/src/features/settings/types/settings.types.ts index 5b13cf8c..fa8f5a08 100644 --- a/windows/tauri/src/features/settings/types/settings.types.ts +++ b/windows/tauri/src/features/settings/types/settings.types.ts @@ -102,6 +102,7 @@ export interface Settings { hiddenSidebarActivityItems: string[]; footerLeadingItemsOrder: FooterLeadingItemId[]; footerTrailingItemsOrder: FooterTrailingItemId[]; + askWhereToOpenProjects: boolean; openFoldersInNewWindow: boolean; // AI aiProviderId: string; @@ -123,7 +124,6 @@ export interface Settings { // Layout activityRailExpanded: boolean; activityRailWidth: number; - showActivityRailProjectSwitcher: boolean; showActivityRailAgentHistory: boolean; showActivityRailTerminals: boolean; showActivityRailWorktrees: boolean; diff --git a/windows/tauri/src/features/window/components/new-project-content.tsx b/windows/tauri/src/features/window/components/new-project-content.tsx index 7d3e3183..db8138f3 100644 --- a/windows/tauri/src/features/window/components/new-project-content.tsx +++ b/windows/tauri/src/features/window/components/new-project-content.tsx @@ -47,6 +47,7 @@ import { useTranslation } from "@/i18n/locale-provider"; interface NewProjectContentProps { onBack: () => void; onClose: () => void; + initialSource?: NewProjectSource; } interface ProjectSourceOption { @@ -64,14 +65,20 @@ const packageManagerOptions = [ { value: "bun", label: "Bun" }, ]; -export default function NewProjectContent({ onBack, onClose }: NewProjectContentProps) { +export default function NewProjectContent({ + onBack, + onClose, + initialSource, +}: NewProjectContentProps) { const { t } = useTranslation(); const nameInputRef = useRef(null); const repositoryInputRef = useRef(null); - const [step, setStep] = useState<"source" | "details" | "creating">("source"); + const [step, setStep] = useState<"source" | "details" | "creating">( + initialSource ? "details" : "source", + ); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); - const [source, setSource] = useState("empty"); + const [source, setSource] = useState(initialSource ?? "empty"); const [projectName, setProjectName] = useState(""); const [repositoryUrl, setRepositoryUrl] = useState(""); const [locationPath, setLocationPath] = useState(""); diff --git a/windows/tauri/src/features/window/components/project-picker.tsx b/windows/tauri/src/features/window/components/project-picker.tsx index 8cd73791..b5f5972b 100644 --- a/windows/tauri/src/features/window/components/project-picker.tsx +++ b/windows/tauri/src/features/window/components/project-picker.tsx @@ -52,11 +52,16 @@ import { Spinner } from "@/ui/spinner"; import { toast } from "sonner"; import { cn } from "@/utils/cn"; import { connectionStore } from "@/features/remote/stores/remote-connection.store"; +import { + getProjectPickerInitialState, + type ProjectPickerMode, +} from "@/features/window/utils/project-picker-mode"; import NewProjectContent from "./new-project-content"; interface ProjectPickerProps { isOpen: boolean; onClose: () => void; + initialMode?: ProjectPickerMode; } const createRemoteConnectionFormData = (): RemoteConnectionFormData => ({ @@ -70,8 +75,9 @@ const createRemoteConnectionFormData = (): RemoteConnectionFormData => ({ saveCredentials: false, }); -const ProjectPicker = memo(({ isOpen, onClose }: ProjectPickerProps) => { - const { t } = useTranslation(); +const ProjectPicker = memo( + ({ isOpen, onClose, initialMode = "picker" }: ProjectPickerProps) => { + const { t } = useTranslation(); const inputRef = useRef(null); const remoteNameInputRef = useRef(null); const [connections, setConnections] = useState([]); @@ -126,11 +132,13 @@ const ProjectPicker = memo(({ isOpen, onClose }: ProjectPickerProps) => { } }, []); + const initialPickerState = getProjectPickerInitialState(initialMode); + useEffect(() => { if (isOpen) { setQuery(""); setSelectedIndex(0); - setCommandStep("picker"); + setCommandStep(initialPickerState.commandStep); setRemoteFormData(createRemoteConnectionFormData()); setShowRemotePassword(false); setRemoteValidationStatus("idle"); @@ -141,7 +149,7 @@ const ProjectPicker = memo(({ isOpen, onClose }: ProjectPickerProps) => { loadWslDistributions(); window.setTimeout(() => inputRef.current?.focus(), 0); } - }, [isOpen, loadConnections, loadWslDistributions]); + }, [initialPickerState.commandStep, isOpen, loadConnections, loadWslDistributions]); useEffect(() => { if (!isOpen) return; @@ -425,16 +433,22 @@ const ProjectPicker = memo(({ isOpen, onClose }: ProjectPickerProps) => { isVisible={isOpen} onClose={onClose} title={ - commandStep === "addRemote" - ? t("welcome.newRemoteConnection") - : commandStep === "newProject" - ? t("welcome.newProject") - : t("welcome.openProject") + commandStep === "addRemote" + ? t("welcome.newRemoteConnection") + : commandStep === "newProject" + ? initialMode === "clone-repository" + ? t("welcome.cloneRepository") + : t("welcome.newProject") + : t("welcome.openProject") } autoFocus={commandStep === "picker"} > {commandStep === "newProject" ? ( - + ) : commandStep === "picker" ? ( @@ -698,7 +712,8 @@ const ProjectPicker = memo(({ isOpen, onClose }: ProjectPickerProps) => { /> ); -}); + }, +); ProjectPicker.displayName = "ProjectPicker"; diff --git a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx index 32eb8022..971b133b 100644 --- a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx +++ b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx @@ -12,8 +12,10 @@ import SettingsDialog from "@/features/settings/components/settings-dialog"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import type { ProjectPickerMode } from "@/features/window/utils/project-picker-mode"; import { useNativeWindowChrome } from "@/features/window/hooks/use-native-window-chrome"; import { createAppWindow } from "@/features/window/utils/create-app-window"; +import { runTitleBarDrag } from "@/features/window/utils/title-bar-drag"; import { Button } from "@/ui/button"; import { ChromeBar, ChromeGroup } from "@/ui/chrome"; import { @@ -36,11 +38,13 @@ import Tooltip from "@/ui/tooltip"; import { cn } from "@/utils/cn"; import { IS_LINUX, IS_MAC, IS_WINDOWS } from "@/utils/platform"; import ProjectPicker from "../project-picker"; +import { TitleProjectMenu } from "./title-project-menu"; import { WindowControls } from "./window-controls"; import WindowMenuBar from "../window-menu-bar"; interface TitleBarProps { showMinimal?: boolean; + onOpenProjectPicker: (mode?: ProjectPickerMode) => void; } function TitleBarTrailingActions({ items }: { items: Array> }) { @@ -57,7 +61,7 @@ function TitleBarTrailingActions({ items }: { items: Array { +const TitleBar = ({ showMinimal = false, onOpenProjectPicker }: TitleBarProps) => { const { t } = useTranslation(); const nativeMenuBar = useSettingsStore((state) => state.settings.nativeMenuBar); const compactMenuBar = useSettingsStore((state) => state.settings.compactMenuBar); @@ -67,7 +71,6 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { const handleOpenFolder = useFileSystemStore((state) => state.handleOpenFolder); const closeProject = useFileSystemStore((state) => state.closeProject); const projectTabs = useWorkspaceTabsStore.use.projectTabs(); - const setIsProjectPickerVisible = useUIState((state) => state.setIsProjectPickerVisible); const setIsGlobalSearchVisible = useUIState((state) => state.setIsGlobalSearchVisible); const branchItem = useFooterGitBranchItem(); @@ -143,17 +146,10 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { }; const handleTitleBarMouseDown = (e: React.MouseEvent) => { - if (e.button !== 0) return; - - const target = e.target as HTMLElement; - const interactiveTarget = target.closest( - "button, a, input, textarea, select, [role='tab'], [contenteditable='true']", - ); - - if (interactiveTarget) return; - - void currentWindow?.startDragging().catch((error: unknown) => { - console.error("Error starting window drag:", error); + runTitleBarDrag(e, () => { + void currentWindow?.startDragging().catch((error: unknown) => { + console.error("Error starting window drag:", error); + }); }); }; @@ -191,7 +187,7 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { New Window - setIsProjectPickerVisible(true)}> + onOpenProjectPicker()}> Add Project @@ -248,20 +244,9 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { const headerTrailingItems: Array> = []; const orderedTrailingItems = orderChromeItems(headerTrailingItems, headerTrailingItemsOrder); - const activeProject = projectTabs.find((project) => project.isActive); - const projectLabel = activeProject?.name ?? t("workbench.openProject"); const macOSAlignedControls = ( - + {branchItem?.content} ); @@ -299,7 +284,7 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { size="icon-xs" tooltip={t("workbench.moreProjectActions")} tooltipSide="bottom" - onClick={() => setIsProjectPickerVisible(true)} + onClick={() => onOpenProjectPicker()} aria-label={t("workbench.moreProjectActions")} > @@ -383,23 +368,38 @@ const TitleBar = ({ showMinimal = false }: TitleBarProps) => { ); }; -const TitleBarWithSettings = (props: TitleBarProps) => { +const TitleBarWithSettings = ({ + showMinimal = false, +}: Omit) => { const isSettingsDialogVisible = useUIState((state) => state.isSettingsDialogVisible); const isProjectPickerVisible = useUIState((state) => state.isProjectPickerVisible); const setIsSettingsDialogVisible = useUIState((state) => state.setIsSettingsDialogVisible); const setIsProjectPickerVisible = useUIState((state) => state.setIsProjectPickerVisible); + const [projectPickerMode, setProjectPickerMode] = useState("picker"); + const openProjectPicker = useCallback( + (mode: ProjectPickerMode = "picker") => { + setProjectPickerMode(mode); + setIsProjectPickerVisible(true); + }, + [setIsProjectPickerVisible], + ); + const closeProjectPicker = useCallback(() => { + setProjectPickerMode("picker"); + setIsProjectPickerVisible(false); + }, [setIsProjectPickerVisible]); return ( <> - + setIsSettingsDialogVisible(false)} /> {createPortal( setIsProjectPickerVisible(false)} + onClose={closeProjectPicker} />, document.body, )} diff --git a/windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx b/windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx new file mode 100644 index 00000000..1e7cebdd --- /dev/null +++ b/windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx @@ -0,0 +1,228 @@ +import { convertFileSrc } from "@/platform/tauri-core"; +import { useMemo, useState, type ReactNode } from "react"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useRecentFoldersStore } from "@/features/file-system/stores/recent-folders.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { + getTitleProjectBadge, + getTitleProjectMenuItemAriaCurrent, + getTitleProjectMenuProjects, +} from "@/features/window/utils/title-project-menu-model"; +import type { ProjectPickerMode } from "@/features/window/utils/project-picker-mode"; +import { + CheckIcon, + ChevronDownIcon, + FolderOpenIcon, + GitBranchIcon, + PlusIcon, +} from "@/ui/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/ui/dropdown"; +import { Button } from "@/ui/button"; +import { cn } from "@/utils/cn"; + +interface TitleProjectMenuProps { + onOpenProjectPicker: (mode: ProjectPickerMode) => void; +} + +function ProjectBadge({ + name, + iconPath, + className, +}: { + name: string; + iconPath?: string; + className?: string; +}) { + if (iconPath) { + return ( + + ); + } + + const badge = getTitleProjectBadge(name); + return ( + + ); +} + +function ProjectMenuRow({ + name, + path, + iconPath, + active = false, + trailing, + onClick, + disabled = false, +}: { + name: string; + path: string; + iconPath?: string; + active?: boolean; + trailing?: ReactNode; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + + + {name} + {path} + + {trailing} + + ); +} + +export function TitleProjectMenu({ onOpenProjectPicker }: TitleProjectMenuProps) { + const { t } = useTranslation(); + const projectTabs = useWorkspaceTabsStore.use.projectTabs(); + const activeProject = projectTabs.find((project) => project.isActive); + const recentFolders = useRecentFoldersStore((state) => state.recentFolders); + const openRecentFolder = useRecentFoldersStore((state) => state.actions.openRecentFolder); + const handleOpenFolder = useFileSystemStore((state) => state.handleOpenFolder); + const switchToProject = useFileSystemStore((state) => state.switchToProject); + const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject); + const projectLabel = activeProject?.name ?? t("projectOpen.title"); + const [isOpen, setIsOpen] = useState(false); + const projects = useMemo( + () => getTitleProjectMenuProjects(projectTabs, recentFolders), + [projectTabs, recentFolders], + ); + + const closeAndRun = (action: () => void) => { + setIsOpen(false); + action(); + }; + + return ( + + + } + > + + {projectLabel} + + + + + closeAndRun(() => onOpenProjectPicker("new-project"))} + className="min-h-8 justify-start gap-2 rounded-md px-2" + > + + {t("titleProject.newProject")} + + closeAndRun(() => void handleOpenFolder())} + className="min-h-8 justify-start gap-2 rounded-md px-2" + > + + {t("titleProject.open")} + + closeAndRun(() => onOpenProjectPicker("clone-repository"))} + className="min-h-8 justify-start gap-2 rounded-md px-2" + > + + {t("titleProject.cloneRepository")} + + + + + + {t("titleProject.openProjects")} + + {projects.openProjects.map((project) => ( + { + if (project.isActive) return; + closeAndRun(() => void switchToProject(project.id)); + }} + trailing={project.isActive ? : null} + /> + ))} + + + + + + {t("titleProject.recentProjects")} + + {projects.recentProjects.length === 0 ? ( +
+ {t("titleProject.noRecentProjects")} +
+ ) : null} + {projects.recentProjects.map((folder) => ( + closeAndRun(() => void openRecentFolder(folder.path))} + /> + ))} +
+
+
+ ); +} diff --git a/windows/tauri/src/features/window/utils/project-picker-mode.test.ts b/windows/tauri/src/features/window/utils/project-picker-mode.test.ts new file mode 100644 index 00000000..b69cbf48 --- /dev/null +++ b/windows/tauri/src/features/window/utils/project-picker-mode.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { getProjectPickerInitialState } from "./project-picker-mode"; + +describe("project picker launch mode", () => { + test("opens the searchable picker by default", () => { + expect(getProjectPickerInitialState("picker")).toEqual({ + commandStep: "picker", + newProjectSource: undefined, + }); + }); + + test("opens the new project source chooser", () => { + expect(getProjectPickerInitialState("new-project")).toEqual({ + commandStep: "newProject", + newProjectSource: undefined, + }); + }); + + test("opens the clone repository details form directly", () => { + expect(getProjectPickerInitialState("clone-repository")).toEqual({ + commandStep: "newProject", + newProjectSource: "clone", + }); + }); +}); diff --git a/windows/tauri/src/features/window/utils/project-picker-mode.ts b/windows/tauri/src/features/window/utils/project-picker-mode.ts new file mode 100644 index 00000000..5d4799e0 --- /dev/null +++ b/windows/tauri/src/features/window/utils/project-picker-mode.ts @@ -0,0 +1,12 @@ +export type ProjectPickerMode = "picker" | "new-project" | "clone-repository"; + +export function getProjectPickerInitialState(mode: ProjectPickerMode) { + if (mode === "picker") { + return { commandStep: "picker" as const, newProjectSource: undefined }; + } + + return { + commandStep: "newProject" as const, + newProjectSource: mode === "clone-repository" ? ("clone" as const) : undefined, + }; +} diff --git a/windows/tauri/src/features/window/utils/title-bar-drag.test.ts b/windows/tauri/src/features/window/utils/title-bar-drag.test.ts new file mode 100644 index 00000000..da6cdffd --- /dev/null +++ b/windows/tauri/src/features/window/utils/title-bar-drag.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { runTitleBarDrag } from "./title-bar-drag"; + +type TitleBarMouseDownEvent = Parameters[0]; + +function createMouseDownEvent({ + contained, + interactive = false, +}: { + contained: boolean; + interactive?: boolean; +}): TitleBarMouseDownEvent { + const target = { + closest: () => (interactive ? {} : null), + }; + + return { + button: 0, + target, + currentTarget: { + contains: () => contained, + }, + } as unknown as TitleBarMouseDownEvent; +} + +test("title bar drag ignores mouse events from portaled content", () => { + let dragCount = 0; + + const didStart = runTitleBarDrag(createMouseDownEvent({ contained: false }), () => { + dragCount += 1; + }); + + expect(didStart).toBe(false); + expect(dragCount).toBe(0); +}); + +test("title bar drag starts for contained non-interactive content", () => { + let dragCount = 0; + + const didStart = runTitleBarDrag(createMouseDownEvent({ contained: true }), () => { + dragCount += 1; + }); + + expect(didStart).toBe(true); + expect(dragCount).toBe(1); +}); + +test("title bar drag ignores contained interactive controls", () => { + let dragCount = 0; + + const didStart = runTitleBarDrag( + createMouseDownEvent({ contained: true, interactive: true }), + () => { + dragCount += 1; + }, + ); + + expect(didStart).toBe(false); + expect(dragCount).toBe(0); +}); diff --git a/windows/tauri/src/features/window/utils/title-bar-drag.ts b/windows/tauri/src/features/window/utils/title-bar-drag.ts new file mode 100644 index 00000000..9c6039d6 --- /dev/null +++ b/windows/tauri/src/features/window/utils/title-bar-drag.ts @@ -0,0 +1,23 @@ +import type { MouseEvent } from "react"; + +type TitleBarMouseDownEvent = Pick< + MouseEvent, + "button" | "currentTarget" | "target" +>; + +const INTERACTIVE_TARGET_SELECTOR = + "button, a, input, textarea, select, [role='tab'], [contenteditable='true']"; + +export function runTitleBarDrag( + event: TitleBarMouseDownEvent, + startDragging: () => void, +): boolean { + if (event.button !== 0) return false; + + const target = event.target as HTMLElement; + if (!event.currentTarget.contains(target)) return false; + if (target.closest(INTERACTIVE_TARGET_SELECTOR)) return false; + + startDragging(); + return true; +} diff --git a/windows/tauri/src/features/window/utils/title-project-menu-model.test.ts b/windows/tauri/src/features/window/utils/title-project-menu-model.test.ts new file mode 100644 index 00000000..3cb1f2b2 --- /dev/null +++ b/windows/tauri/src/features/window/utils/title-project-menu-model.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import type { RecentFolder } from "@/features/file-system/types/recent-folders.types"; +import type { ProjectTab } from "@/features/window/stores/workspace-tabs.store"; +import { + getTitleProjectBadge, + getTitleProjectMenuItemAriaCurrent, + getTitleProjectMenuProjects, +} from "./title-project-menu-model"; + +const project = (id: string, path: string, isActive = false): ProjectTab => ({ + id, + name: id, + path, + isActive, + lastOpened: 1, +}); + +const recent = (name: string, path: string): RecentFolder => ({ + name, + path, + lastOpened: "2026-08-16T00:00:00.000Z", +}); + +describe("title project menu model", () => { + test("keeps open projects and active state while excluding open recent paths", () => { + const openProjects = [ + project("Lithe", "D:\\code\\Lithe", true), + project("Online", "D:\\code\\online"), + ]; + + const result = getTitleProjectMenuProjects(openProjects, [ + recent("Lithe", "d:/code/Lithe/"), + recent("New", "D:\\code\\new"), + ]); + + expect(result.openProjects).toEqual(openProjects); + expect(result.recentProjects.map((entry) => entry.name)).toEqual(["New"]); + }); + + test("caps recent projects without mutating the source list", () => { + const recentProjects = [ + recent("A", "D:\\code\\a"), + recent("B", "D:\\code\\b"), + recent("C", "D:\\code\\c"), + ]; + + const result = getTitleProjectMenuProjects([], recentProjects, 2); + + expect(result.recentProjects).toEqual(recentProjects.slice(0, 2)); + expect(recentProjects).toHaveLength(3); + }); + + test("keeps existing projects open when a recent project opens in this window", () => { + const existingProject = project("A", "D:\\code\\a", true); + const recentProject = recent("B", "D:\\code\\b"); + + const beforeOpen = getTitleProjectMenuProjects([existingProject], [recentProject]); + expect(beforeOpen.recentProjects.map((entry) => entry.name)).toEqual(["B"]); + + const openedProject = project("B", "D:\\code\\b", true); + const afterOpen = getTitleProjectMenuProjects( + [{ ...existingProject, isActive: false }, openedProject], + [recentProject], + ); + + expect(afterOpen.openProjects.map((entry) => entry.name)).toEqual(["A", "B"]); + expect(afterOpen.recentProjects).toEqual([]); + }); + + test("marks only the active project as current for assistive technology", () => { + expect(getTitleProjectMenuItemAriaCurrent(true)).toBe("true"); + expect(getTitleProjectMenuItemAriaCurrent(false)).toBeUndefined(); + }); + + test("creates deterministic project initials and badge tones", () => { + const badge = getTitleProjectBadge("Lithe-IDEA-issue-35-ci"); + + expect(badge.initials).toBe("LI"); + expect(getTitleProjectBadge("Lithe-IDEA-issue-35-ci")).toEqual(badge); + expect(getTitleProjectBadge("文档项目").initials).toBe("文"); + }); +}); diff --git a/windows/tauri/src/features/window/utils/title-project-menu-model.ts b/windows/tauri/src/features/window/utils/title-project-menu-model.ts new file mode 100644 index 00000000..e5cca4c7 --- /dev/null +++ b/windows/tauri/src/features/window/utils/title-project-menu-model.ts @@ -0,0 +1,51 @@ +import type { RecentFolder } from "@/features/file-system/types/recent-folders.types"; +import type { ProjectTab } from "@/features/window/stores/workspace-tabs.store"; +import { MAX_RECENT_PROJECTS } from "@/features/file-system/utils/recent-folders"; +import { areProjectTabPathsEqual } from "./project-tab-path"; + +const PROJECT_BADGE_TONES = [ + "bg-sky-600", + "bg-emerald-600", + "bg-orange-600", + "bg-violet-600", + "bg-rose-600", +] as const; + +export function getTitleProjectBadge(name: string) { + const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const initials = + words + .slice(0, 2) + .map((word) => Array.from(word)[0] ?? "") + .join("") + .toUpperCase() || "LI"; + const hash = Array.from(name).reduce( + (value, character) => (value * 31 + (character.codePointAt(0) ?? 0)) & 0x7fffffff, + 0, + ); + + return { + initials, + tone: PROJECT_BADGE_TONES[hash % PROJECT_BADGE_TONES.length] ?? PROJECT_BADGE_TONES[0], + }; +} + +export function getTitleProjectMenuItemAriaCurrent(isActive: boolean): "true" | undefined { + return isActive ? "true" : undefined; +} + +export function getTitleProjectMenuProjects( + projectTabs: ProjectTab[], + recentFolders: RecentFolder[], + maxRecentProjects = MAX_RECENT_PROJECTS, +) { + return { + openProjects: projectTabs, + recentProjects: recentFolders + .filter( + (recent) => + !projectTabs.some((project) => areProjectTabPathsEqual(project.path, recent.path)), + ) + .slice(0, maxRecentProjects), + }; +} diff --git a/windows/tauri/src/i18n/locale.test.ts b/windows/tauri/src/i18n/locale.test.ts index 6110a251..ca9b9a9b 100644 --- a/windows/tauri/src/i18n/locale.test.ts +++ b/windows/tauri/src/i18n/locale.test.ts @@ -85,4 +85,41 @@ describe("Windows display language", () => { expect(chineseKeys).toEqual(englishKeys); }); + + test("translates the project open destination prompt", () => { + const english = createTranslator("en-US"); + const chinese = createTranslator("zh-CN"); + + expect(english("projectOpen.title")).toBe("Open Project"); + expect(english("projectOpen.where", { project: "Lithe" })).toBe( + 'Where do you want to open project "Lithe"?', + ); + expect(english("projectOpen.doNotAskAgain")).toBe("Do not ask again"); + expect(english("projectOpen.cancel")).toBe("Cancel"); + expect(english("projectOpen.newWindow")).toBe("New Window"); + expect(english("projectOpen.thisWindow")).toBe("This Window"); + expect(english("titleProject.newProject")).toBe("New Project…"); + expect(english("titleProject.open")).toBe("Open…"); + expect(english("titleProject.cloneRepository")).toBe("Clone Repository…"); + expect(english("titleProject.openProjects")).toBe("Open Projects"); + expect(english("titleProject.recentProjects")).toBe("Recent Projects"); + expect(english("titleProject.noRecentProjects")).toBe("No recent projects"); + expect(english("titleProject.trigger", { project: "Lithe" })).toBe("Project: Lithe"); + + expect(chinese("projectOpen.title")).toBe("打开项目"); + expect(chinese("projectOpen.where", { project: "Lithe" })).toBe( + '你想在哪里打开项目“Lithe”?', + ); + expect(chinese("projectOpen.doNotAskAgain")).toBe("不再询问"); + expect(chinese("projectOpen.cancel")).toBe("取消"); + expect(chinese("projectOpen.newWindow")).toBe("新窗口"); + expect(chinese("projectOpen.thisWindow")).toBe("此窗口"); + expect(chinese("titleProject.newProject")).toBe("新建项目…"); + expect(chinese("titleProject.open")).toBe("打开…"); + expect(chinese("titleProject.cloneRepository")).toBe("克隆仓库…"); + expect(chinese("titleProject.openProjects")).toBe("打开的项目"); + expect(chinese("titleProject.recentProjects")).toBe("最近项目"); + expect(chinese("titleProject.noRecentProjects")).toBe("没有最近项目"); + expect(chinese("titleProject.trigger", { project: "Lithe" })).toBe("项目:Lithe"); + }); }); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index c43d7705..e7089751 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -103,6 +103,19 @@ const catalogs = { "welcome.removeRecent": "Remove {name} from recent projects", "welcome.noRecentProjects": "No recent projects", "welcome.openFolderHint": "Open a folder to get started.", + "projectOpen.title": "Open Project", + "projectOpen.where": 'Where do you want to open project "{project}"?', + "projectOpen.doNotAskAgain": "Do not ask again", + "projectOpen.cancel": "Cancel", + "projectOpen.newWindow": "New Window", + "projectOpen.thisWindow": "This Window", + "titleProject.newProject": "New Project…", + "titleProject.open": "Open…", + "titleProject.cloneRepository": "Clone Repository…", + "titleProject.openProjects": "Open Projects", + "titleProject.recentProjects": "Recent Projects", + "titleProject.noRecentProjects": "No recent projects", + "titleProject.trigger": "Project: {project}", "settings.displayLanguage": "Display Language", "settings.displayLanguageDescription": "Choose the language used throughout Lithe.", "settings.languageEnglish": "English", @@ -1156,6 +1169,19 @@ const catalogs = { "welcome.removeRecent": "从最近项目中移除 {name}", "welcome.noRecentProjects": "暂无最近项目", "welcome.openFolderHint": "打开文件夹以开始使用。", + "projectOpen.title": "打开项目", + "projectOpen.where": "你想在哪里打开项目“{project}”?", + "projectOpen.doNotAskAgain": "不再询问", + "projectOpen.cancel": "取消", + "projectOpen.newWindow": "新窗口", + "projectOpen.thisWindow": "此窗口", + "titleProject.newProject": "新建项目…", + "titleProject.open": "打开…", + "titleProject.cloneRepository": "克隆仓库…", + "titleProject.openProjects": "打开的项目", + "titleProject.recentProjects": "最近项目", + "titleProject.noRecentProjects": "没有最近项目", + "titleProject.trigger": "项目:{project}", "settings.displayLanguage": "显示语言", "settings.displayLanguageDescription": "选择 Lithe 界面使用的语言。", "settings.languageEnglish": "English", diff --git a/windows/tauri/src/ui/dialog.tsx b/windows/tauri/src/ui/dialog.tsx index 978eb6af..9007a2cd 100644 --- a/windows/tauri/src/ui/dialog.tsx +++ b/windows/tauri/src/ui/dialog.tsx @@ -14,6 +14,7 @@ import { AlertDialogTitle, } from "@/ui/alert-dialog"; import { Button, type ButtonVariant } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; import { InfoIcon as Info, type IconProps as AppIconProps, @@ -319,6 +320,17 @@ type PrimitiveDialogRequest = choices: PrimitiveChoiceOption[]; resolve: (value: string | null) => void; } + | { + id: number; + type: "choice-with-checkbox"; + title: string; + message: ReactNode; + choices: PrimitiveChoiceOption[]; + checkboxLabel: string; + checkboxDefaultChecked: boolean; + cancelLabel: string; + resolve: (value: ChoiceWithCheckboxResult | null) => void; + } | { id: number; type: "prompt"; @@ -351,6 +363,18 @@ interface PrimitiveChoiceOptions { }>; } +interface PrimitiveChoiceWithCheckboxOptions + extends PrimitiveChoiceOptions { + checkboxLabel: string; + checkboxDefaultChecked?: boolean; + cancelLabel?: string; +} + +export interface ChoiceWithCheckboxResult { + value: T; + checked: boolean; +} + let nextDialogId = 1; let enqueueDialog: ((request: PrimitiveDialogRequest) => void) | null = null; const pendingDialogs: PrimitiveDialogRequest[] = []; @@ -403,6 +427,25 @@ export function showChoiceDialog( }); } +export function showChoiceDialogWithCheckbox( + message: ReactNode, + options: PrimitiveChoiceWithCheckboxOptions, +): Promise | null> { + return new Promise((resolve) => { + enqueue({ + id: nextDialogId++, + type: "choice-with-checkbox", + title: options.title ?? "Choose", + message, + choices: options.choices, + checkboxLabel: options.checkboxLabel, + checkboxDefaultChecked: options.checkboxDefaultChecked ?? false, + cancelLabel: options.cancelLabel ?? "Cancel", + resolve: (value) => resolve(value as ChoiceWithCheckboxResult | null), + }); + }); +} + export function showPromptDialog( message: ReactNode, options: PrimitivePromptOptions = {}, @@ -463,6 +506,9 @@ function PrimitiveDialogHost({ const [promptValue, setPromptValue] = useState( dialog.type === "prompt" ? dialog.defaultValue : "", ); + const [choiceChecked, setChoiceChecked] = useState( + dialog.type === "choice-with-checkbox" ? dialog.checkboxDefaultChecked : false, + ); if (dialog.type === "alert") { return ( @@ -550,6 +596,49 @@ function PrimitiveDialogHost({ ); } + if (dialog.type === "choice-with-checkbox") { + return ( + { + if (!open) onClose(() => dialog.resolve(null)); + }} + > + + + + + + {dialog.title} + {dialog.message} + + + + {dialog.cancelLabel} + {dialog.choices.map((choice) => ( + + onClose(() => dialog.resolve({ value: choice.value, checked: choiceChecked })) + } + > + {choice.label} + + ))} + + + + ); + } + return ( Date: Mon, 17 Aug 2026 17:31:16 +0800 Subject: [PATCH 2/2] ci(macos): retry stalled Swift tests --- .github/workflows/ci-macos.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e916f7e8..ffdffb8e 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -102,8 +102,16 @@ jobs: key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} - name: Run Swift tests + id: swift-tests-primary + continue-on-error: true + timeout-minutes: 8 run: ./scripts/test-macos.sh + - name: Retry Swift tests after a failed attempt + if: steps.swift-tests-primary.outcome == 'failure' + timeout-minutes: 8 + run: ./scripts/test-macos.sh --skip-build + rust-tests: name: Rust Core and database tests needs: changes