Skip to content
Merged
10 changes: 10 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<SpawnConfig | null>,
): () => void {
if (this.devReloadWatcher) return () => {};

const scriptDir = path.dirname(this.scriptPath);
const scriptFile = path.basename(this.scriptPath);
let debounce: ReturnType<typeof setTimeout> | null = null;
let reloading = false;

const waitForStableBundle = async (): Promise<boolean> => {
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<Connection | null> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function DashboardSidebarWorkspaceItem({
diffStats={diffStats}
/>
}
isLocalWorkspace={hostType === "local-device"}
onCreateSection={handleCreateSection}
onMoveToSection={(targetSectionId) =>
moveWorkspaceToSection(id, projectId, targetSectionId)
Expand Down Expand Up @@ -172,6 +173,7 @@ export function DashboardSidebarWorkspaceItem({
onMoveToSection={(targetSectionId) =>
moveWorkspaceToSection(id, projectId, targetSectionId)
}
isLocalWorkspace={hostType === "local-device"}
onOpenInFinder={handleOpenInFinder}
onCopyPath={handleCopyPath}
onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface DashboardSidebarWorkspaceContextMenuProps {
hoverCardContent?: React.ReactNode;
projectId: string;
isInSection?: boolean;
isLocalWorkspace: boolean;
onHoverCardOpen?: () => void;
onCreateSection: () => void;
onMoveToSection: (sectionId: string | null) => void;
Expand All @@ -46,6 +47,7 @@ interface DashboardSidebarWorkspaceContextMenuProps {
export function DashboardSidebarWorkspaceContextMenu({
projectId,
isInSection,
isLocalWorkspace,
onHoverCardOpen,
hoverCardContent,
onCreateSection,
Expand Down Expand Up @@ -81,15 +83,19 @@ export function DashboardSidebarWorkspaceContextMenu({
<LuPencil className="size-4 mr-2" />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onOpenInFinder}>
<LuFolderOpen className="size-4 mr-2" />
Open in Finder
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyPath}>
<LuCopy className="size-4 mr-2" />
Copy Path
</ContextMenuItem>
{isLocalWorkspace && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onOpenInFinder}>
<LuFolderOpen className="size-4 mr-2" />
Open in Finder
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyPath}>
<LuCopy className="size-4 mr-2" />
Copy Path
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -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<string | null> => {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,32 +22,10 @@ export function FileContextMenu({
<ContextMenuContent className="w-56">
<ContextMenuItem>Open to the Side</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
electronTrpcClient.external.openInFinder.mutate(absolutePath)
}
>
Reveal in Finder
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() => {
navigator.clipboard.writeText(absolutePath);
toast.success("Path copied");
}}
>
Copy Path
</ContextMenuItem>
{relativePath && (
<ContextMenuItem
onSelect={() => {
navigator.clipboard.writeText(relativePath);
toast.success("Relative path copied");
}}
>
Copy Relative Path
</ContextMenuItem>
)}
<PathActionsMenuItems
absolutePath={absolutePath}
relativePath={relativePath}
/>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => setTimeout(onRename, 0)}>
Rename...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,32 +31,10 @@ export function FolderContextMenu({
New Folder...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
electronTrpcClient.external.openInFinder.mutate(absolutePath)
}
>
Reveal in Finder
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() => {
navigator.clipboard.writeText(absolutePath);
toast.success("Path copied");
}}
>
Copy Path
</ContextMenuItem>
{relativePath && (
<ContextMenuItem
onSelect={() => {
navigator.clipboard.writeText(relativePath);
toast.success("Relative path copied");
}}
>
Copy Relative Path
</ContextMenuItem>
)}
<PathActionsMenuItems
absolutePath={absolutePath}
relativePath={relativePath}
/>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => setTimeout(onRename, 0)}>
Rename...
Expand Down
Loading
Loading