diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7e3dac5c25b..788a3a353ec 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,10 +14,12 @@ import { import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { handleAuthCallback, + loadToken, parseAuthDeepLink, } from "lib/trpc/routers/auth/utils/auth-functions"; import { fetchGitHubOwner } from "lib/trpc/routers/projects/utils/github"; import { applyShellEnvToProcess } from "lib/trpc/routers/workspaces/utils/shell-env"; +import { env as mainEnv } from "main/env.main"; import { DEFAULT_CONFIRM_ON_QUIT, PLATFORM, @@ -602,6 +604,14 @@ if (!gotTheLock) { // before the tray initializes, so it shows accurate status immediately. await getHostServiceManager().discoverAll(); + if (IS_DEV) { + getHostServiceManager().enableDevReload(async () => { + const { token } = await loadToken(); + if (!token) return null; + return { authToken: token, cloudApiUrl: mainEnv.NEXT_PUBLIC_API_URL }; + }); + } + await makeAppSetup(() => MainWindow()); setupAutoUpdater(); initTray(); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index efb0df8db4b..a39c1898dde 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -1,6 +1,7 @@ import * as childProcess from "node:child_process"; import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; import { createServer } from "node:net"; import path from "node:path"; import { settings } from "@superset/local-db"; @@ -103,6 +104,7 @@ export class HostServiceCoordinator extends EventEmitter { >(); private scriptPath = path.join(__dirname, "host-service.js"); private machineId = getHashedDeviceId(); + private devReloadWatcher: fs.FSWatcher | null = null; async start( organizationId: string, @@ -222,6 +224,92 @@ export class HostServiceCoordinator extends EventEmitter { ); } + /** + * Dev-only: watch the built host-service bundle and restart running + * instances when it changes. Gives a fast edit→reload loop for code + * under packages/host-service and src/main/host-service without + * restarting Electron. In-memory host-service state (PTYs, watchers, + * chat streams) is torn down on each reload — this is not true HMR. + */ + enableDevReload( + configProvider: () => Promise, + ): () => void { + if (this.devReloadWatcher) return () => {}; + + const scriptDir = path.dirname(this.scriptPath); + const scriptFile = path.basename(this.scriptPath); + let debounce: ReturnType | null = null; + let reloading = false; + + const waitForStableBundle = async (): Promise => { + const deadline = Date.now() + 5_000; + let lastSize = -1; + let stableSince = 0; + while (Date.now() < deadline) { + try { + const stat = fs.statSync(this.scriptPath); + if (stat.size > 0 && stat.size === lastSize) { + if (Date.now() - stableSince >= 150) return true; + } else { + lastSize = stat.size; + stableSince = Date.now(); + } + } catch { + lastSize = -1; + stableSince = 0; + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + }; + + const trigger = () => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + void (async () => { + if (reloading) return; + if (this.getActiveOrganizationIds().length === 0) return; + reloading = true; + try { + const ready = await waitForStableBundle(); + if (!ready) { + console.warn( + "[host-service] bundle did not stabilize, skipping reload", + ); + return; + } + const config = await configProvider(); + if (!config) return; + console.log( + "[host-service] bundle changed, restarting running instances", + ); + await this.restartAll(config); + } catch (error) { + console.error("[host-service] dev reload failed:", error); + } finally { + reloading = false; + } + })(); + }, 250); + }; + + try { + this.devReloadWatcher = fs.watch(scriptDir, (_event, filename) => { + if (filename && filename !== scriptFile) return; + trigger(); + }); + } catch (error) { + console.error("[host-service] failed to enable dev reload:", error); + return () => {}; + } + + return () => { + if (debounce) clearTimeout(debounce); + this.devReloadWatcher?.close(); + this.devReloadWatcher = null; + }; + } + // ── Adoption ────────────────────────────────────────────────────── private async tryAdopt(organizationId: string): Promise { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 9b2b2b4e551..0f3d5435f2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -107,6 +107,7 @@ export function DashboardSidebarWorkspaceItem({ diffStats={diffStats} /> } + isLocalWorkspace={hostType === "local-device"} onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => moveWorkspaceToSection(id, projectId, targetSectionId) @@ -172,6 +173,7 @@ export function DashboardSidebarWorkspaceItem({ onMoveToSection={(targetSectionId) => moveWorkspaceToSection(id, projectId, targetSectionId) } + isLocalWorkspace={hostType === "local-device"} onOpenInFinder={handleOpenInFinder} onCopyPath={handleCopyPath} onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 18872835fa0..29e5e6d1d94 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -32,6 +32,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { hoverCardContent?: React.ReactNode; projectId: string; isInSection?: boolean; + isLocalWorkspace: boolean; onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; @@ -46,6 +47,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { export function DashboardSidebarWorkspaceContextMenu({ projectId, isInSection, + isLocalWorkspace, onHoverCardOpen, hoverCardContent, onCreateSection, @@ -81,15 +83,19 @@ export function DashboardSidebarWorkspaceContextMenu({ Rename - - - - Open in Finder - - - - Copy Path - + {isLocalWorkspace && ( + <> + + + + Open in Finder + + + + Copy Path + + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 629300efa25..8e41962d19b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,12 +1,16 @@ import { toast } from "@superset/ui/sonner"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import { getDeleteFocusTargetWorkspaceId } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId"; import { getFlattenedV2WorkspaceIds } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; @@ -22,6 +26,8 @@ export function useDashboardSidebarWorkspaceItemActions({ const navigate = useNavigate(); const matchRoute = useMatchRoute(); const collections = useCollections(); + const { activeHostUrl } = useLocalHostService(); + const { copyToClipboard } = useCopyToClipboard(); const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } = useDashboardSidebarState(); @@ -106,12 +112,44 @@ export function useDashboardSidebarWorkspaceItemActions({ moveWorkspaceToSection(workspaceId, projectId, newSectionId); }; - const handleOpenInFinder = () => { - toast.info("Open in Finder is coming soon"); + const resolveWorktreePath = async (): Promise => { + if (!activeHostUrl) { + toast.error("Host service is not available"); + return null; + } + const workspace = await getHostServiceClientByUrl( + activeHostUrl, + ).workspace.get.query({ id: workspaceId }); + if (!workspace?.worktreePath) { + toast.error("Workspace path is not available"); + return null; + } + return workspace.worktreePath; + }; + + const handleOpenInFinder = async () => { + try { + const path = await resolveWorktreePath(); + if (!path) return; + await electronTrpcClient.external.openInFinder.mutate(path); + } catch (error) { + toast.error( + `Failed to open in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } }; - const handleCopyPath = () => { - toast.info("Copy Path is coming soon"); + const handleCopyPath = async () => { + try { + const path = await resolveWorktreePath(); + if (!path) return; + await copyToClipboard(path); + toast.success("Path copied"); + } catch (error) { + toast.error( + `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } }; return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx index c0f46ec2566..7c4ff4168d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx @@ -3,8 +3,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from "@superset/ui/context-menu"; -import { toast } from "@superset/ui/sonner"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; interface FileContextMenuProps { absolutePath: string; @@ -23,32 +22,10 @@ export function FileContextMenu({ Open to the Side - - electronTrpcClient.external.openInFinder.mutate(absolutePath) - } - > - Reveal in Finder - - - { - navigator.clipboard.writeText(absolutePath); - toast.success("Path copied"); - }} - > - Copy Path - - {relativePath && ( - { - navigator.clipboard.writeText(relativePath); - toast.success("Relative path copied"); - }} - > - Copy Relative Path - - )} + setTimeout(onRename, 0)}> Rename... diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx index de25fe6bb8f..119e2eb84fe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx @@ -3,8 +3,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from "@superset/ui/context-menu"; -import { toast } from "@superset/ui/sonner"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; interface FolderContextMenuProps { absolutePath: string; @@ -32,32 +31,10 @@ export function FolderContextMenu({ New Folder... - - electronTrpcClient.external.openInFinder.mutate(absolutePath) - } - > - Reveal in Finder - - - { - navigator.clipboard.writeText(absolutePath); - toast.success("Path copied"); - }} - > - Copy Path - - {relativePath && ( - { - navigator.clipboard.writeText(relativePath); - toast.success("Relative path copied"); - }} - > - Copy Relative Path - - )} + setTimeout(onRename, 0)}> Rename... diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx new file mode 100644 index 00000000000..c1bf48057ba --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx @@ -0,0 +1,59 @@ +import { + ContextMenuItem, + ContextMenuSeparator, +} from "@superset/ui/context-menu"; +import { toast } from "@superset/ui/sonner"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface PathActionsMenuItemsProps { + absolutePath: string; + relativePath?: string; +} + +export function PathActionsMenuItems({ + absolutePath, + relativePath, +}: PathActionsMenuItemsProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleCopy = async (path: string, successMessage: string) => { + try { + await copyToClipboard(path); + toast.success(successMessage); + } catch (error) { + toast.error( + `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + + const handleRevealInFinder = async () => { + try { + await electronTrpcClient.external.openInFinder.mutate(absolutePath); + } catch (error) { + toast.error( + `Failed to reveal in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + + return ( + <> + + Reveal in Finder + + + handleCopy(absolutePath, "Path copied")}> + Copy Path + + {relativePath && ( + handleCopy(relativePath, "Relative path copied")} + > + Copy Relative Path + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts new file mode 100644 index 00000000000..2f4345f1fcd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts @@ -0,0 +1 @@ +export { PathActionsMenuItems } from "./PathActionsMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index bc6eba977ee..59c1fc0f4db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -29,7 +29,7 @@ function V2WorkspaceLayout() { const { machineId, activeHostUrl } = useLocalHostService(); const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - const { data: workspacesWithHost = [] } = useLiveQuery( + const { data: workspacesWithHost = [], isReady } = useLiveQuery( (q) => q .from({ v2Workspaces: collections.v2Workspaces }) @@ -64,22 +64,14 @@ function V2WorkspaceLayout() { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); }, [ensureWorkspaceInSidebar, workspace]); - // TODO: This renders child routes without WorkspaceTrpcProvider when - // the workspace hasn't loaded from collections yet, or during route - // transitions (e.g. navigating away from a workspace). If the outgoing - // workspace page hasn't fully unmounted, its components (TerminalPane, - // etc.) will crash with "useWorkspaceClient must be used within - // WorkspaceClientProvider". Either the layout should never render - // children without the provider, or the provider should move to the - // page level so each page owns its own context. - if (!workspaceId || !workspace) { - return ; + if (!workspaceId || !isReady) { + return null; } - if (!hostUrl) { + if (!workspace || !hostUrl) { return (
- Workspace host service not available + Workspace not found
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 6af331ed51d..14ff93c3e0b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -5,10 +5,12 @@ import { useNavigate, } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { type SettingsSection, useSetSettingsSearchQuery, + useSettingsOriginRoute, useSettingsSearchQuery, } from "renderer/stores/settings-state"; import { SearchResultsBanner } from "./components/SearchResultsBanner"; @@ -105,6 +107,7 @@ function SettingsLayout() { const isMac = platform === undefined || platform === "darwin"; const searchQuery = useSettingsSearchQuery(); const setSearchQuery = useSetSettingsSearchQuery(); + const originRoute = useSettingsOriginRoute(); const location = useLocation(); const navigate = useNavigate(); const normalizedSearchQuery = searchQuery.trim(); @@ -134,6 +137,29 @@ function SettingsLayout() { } }, [isSearchActive, location.pathname, navigate, normalizedSearchQuery]); + useHotkeys( + "escape", + (event) => { + // FORK NOTE: upstream #3466 used `[data-state="open"]` which also + // matches Radix Collapsible (AgentCard etc.), silently disabling + // Escape whenever any card was expanded. Narrow to explicit + // overlay shapes: role-based (Dialog/AlertDialog/Menu/Select) plus + // popper-based content (Popover/HoverCard) which has no semantic + // role but is always rendered inside [data-radix-popper-content-wrapper]. + // Collapsible is inline (not popper), so it stays excluded. + if ( + document.querySelector( + '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"], [data-radix-popper-content-wrapper] [data-state="open"]', + ) + ) + return; + event.preventDefault(); + navigate({ to: originRoute, replace: true }); + }, + { enableOnFormTags: false, enableOnContentEditable: false }, + [navigate, originRoute], + ); + return (
{ + if (!event.metaKey && !event.ctrlKey) { + return; + } if (onFileLinkClick) { onFileLinkClick(event, link); return;