Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 12 additions & 20 deletions apps/api/src/app/api/electric/[...path]/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
subscriptions,
taskStatuses,
tasks,
v2DevicePresence,
v2Devices,
v2Clients,
v2Hosts,
v2Projects,
v2UsersDevices,
v2UsersHosts,
v2Workspaces,
workspaces,
} from "@superset/db/schema";
Expand All @@ -29,10 +29,10 @@ export type AllowedTable =
| "tasks"
| "task_statuses"
| "projects"
| "v2_devices"
| "v2_device_presence"
| "v2_hosts"
| "v2_clients"
| "v2_projects"
| "v2_users_devices"
| "v2_users_hosts"
| "v2_workspaces"
| "auth.members"
| "auth.organizations"
Expand Down Expand Up @@ -84,22 +84,14 @@ export async function buildWhereClause(
case "v2_projects":
return build(v2Projects, v2Projects.organizationId, organizationId);

case "v2_devices":
return build(v2Devices, v2Devices.organizationId, organizationId);
case "v2_hosts":
return build(v2Hosts, v2Hosts.organizationId, organizationId);

case "v2_device_presence":
return build(
v2DevicePresence,
v2DevicePresence.organizationId,
organizationId,
);
case "v2_clients":
return build(v2Clients, v2Clients.organizationId, organizationId);

case "v2_users_devices":
return build(
v2UsersDevices,
v2UsersDevices.organizationId,
organizationId,
);
case "v2_users_hosts":
return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId);

case "v2_workspaces":
return build(v2Workspaces, v2Workspaces.organizationId, organizationId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type DiffStats, useDiffStats } from "./useDiffStats";
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from "react";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useWorkspaceEvent } from "../useWorkspaceEvent";
import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl";

export interface DiffStats {
additions: number;
deletions: number;
}

/**
* Fetches diff stats for a single workspace, auto-updates on git changes.
* Just pass the workspaceId — host resolution is handled internally.
*/
export function useDiffStats(workspaceId: string): DiffStats | null {
const [stats, setStats] = useState<DiffStats | null>(null);
const hostUrl = useWorkspaceHostUrl(workspaceId);

const fetchStats = useCallback(async () => {
if (!hostUrl) return;
try {
const client = getHostServiceClientByUrl(hostUrl);
const status = await client.git.getStatus.query({ workspaceId });

// Deduplicate by path — a file can appear in multiple categories
const byPath = new Map<
string,
{ additions: number; deletions: number }
>();
for (const file of status.againstBase) {
byPath.set(file.path, file);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
for (const file of status.staged) {
byPath.set(file.path, file);
}
for (const file of status.unstaged) {
byPath.set(file.path, file);
}

let additions = 0;
let deletions = 0;
for (const file of byPath.values()) {
additions += file.additions;
deletions += file.deletions;
}

setStats({ additions, deletions });
} catch {
// Host unavailable or workspace deleted
}
}, [hostUrl, workspaceId]);

useEffect(() => {
void fetchStats();
}, [fetchStats]);

useWorkspaceEvent("git:changed", workspaceId, () => {
void fetchStats();
});

return stats;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { workspaceTrpc } from "@superset/workspace-client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { workspaceTrpc } from "../../workspace-trpc";
import { useWorkspaceFsEvents } from "../useWorkspaceFsEvents";
import { useWorkspaceEvent } from "../useWorkspaceEvent";

const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
const BINARY_CHECK_SIZE = 8192;
Expand Down Expand Up @@ -162,7 +162,8 @@ export function useFileDocument({
setConflict({ diskContent });
}, [fetchCurrentDiskContent, mode]);

useWorkspaceFsEvents(
useWorkspaceEvent(
"fs:events",
workspaceId,
(event) => {
const path = currentPathRef.current;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/host";
import { workspaceTrpc } from "@superset/workspace-client";
import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { workspaceTrpc } from "../../workspace-trpc";
import { useWorkspaceFsEvents } from "../useWorkspaceFsEvents";
import { useWorkspaceEvent } from "../useWorkspaceEvent";

export interface FileTreeNode {
absolutePath: string;
Expand Down Expand Up @@ -354,7 +354,8 @@ export function useFileTree({
void loadDirectory(rootPath, true);
}, [loadDirectory, rootPath, updateState]);

useWorkspaceFsEvents(
useWorkspaceEvent(
"fs:events",
workspaceId,
(event) => {
if (!rootPath) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useWorkspaceEvent } from "./useWorkspaceEvent";
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getEventBus } from "@superset/workspace-client";
import type { FsWatchEvent } from "@superset/workspace-fs/client";
import { useEffect, useEffectEvent } from "react";
import { getHostServiceWsToken } from "renderer/lib/host-service-auth";
import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl";

/**
* Subscribe to an event bus event for a workspace.
* Resolves the workspace's host and connects to the correct event bus automatically.
*/
export function useWorkspaceEvent(
type: "git:changed",
workspaceId: string,
callback: () => void,
enabled?: boolean,
): void;
export function useWorkspaceEvent(
type: "fs:events",
workspaceId: string,
callback: (event: FsWatchEvent) => void,
enabled?: boolean,
): void;
export function useWorkspaceEvent(
type: "git:changed" | "fs:events",
workspaceId: string,
callback: ((event: FsWatchEvent) => void) | (() => void),
enabled = true,
): void {
const hostUrl = useWorkspaceHostUrl(workspaceId);
const handler = useEffectEvent(callback);

useEffect(() => {
if (!enabled || !hostUrl) return;

const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl));
const cleanups: Array<() => void> = [];

if (type === "fs:events") {
bus.watchFs(workspaceId);
const removeListener = bus.on(
"fs:events",
workspaceId,
(_wid, payload) => {
for (const event of payload.events) {
(handler as (event: FsWatchEvent) => void)(event);
}
},
);
cleanups.push(removeListener, () => bus.unwatchFs(workspaceId));
} else {
Comment on lines +39 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify event-bus fs watch/listener semantics (read-only).
# Expected: confirm whether watchFs sends subscription immediately and whether inbound fs events are buffered or only dispatched to current listeners.

set -euo pipefail

FILE="packages/workspace-client/src/lib/eventBus.ts"

echo "== watch/unwatch and listener APIs =="
rg -n -C4 'watchFs\s*\(|unwatchFs\s*\(|\bon\s*\(' "$FILE"

echo
echo "== inbound ws message dispatch paths =="
rg -n -C5 'onmessage|message|emit|dispatch|listeners|fs:events|git:changed' "$FILE"

Repository: superset-sh/superset

Length of output: 4748


Attach the fs listener before calling watchFs to prevent dropped events.

At line 44, watchFs is called before the listener is registered on line 45. This creates a race condition where "fs:events" messages arriving between the fs:watch command and listener registration are silently dropped, since handleMessage only dispatches to listeners present in state.listeners at message arrival time.

Suggested fix
 		if (type === "fs:events") {
-			bus.watchFs(workspaceId);
 			const removeListener = bus.on(
 				"fs:events",
 				workspaceId,
 				(_wid, payload) => {
 					for (const event of payload.events) {
 						(handler as (event: FsWatchEvent) => void)(event);
 					}
 				},
 			);
-			cleanups.push(removeListener, () => bus.unwatchFs(workspaceId));
+			cleanups.push(removeListener);
+			bus.watchFs(workspaceId);
+			cleanups.push(() => bus.unwatchFs(workspaceId));
 		} else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bus.watchFs(workspaceId);
const removeListener = bus.on(
"fs:events",
workspaceId,
(_wid, payload) => {
for (const event of payload.events) {
(handler as (event: FsWatchEvent) => void)(event);
}
},
);
cleanups.push(removeListener, () => bus.unwatchFs(workspaceId));
} else {
const removeListener = bus.on(
"fs:events",
workspaceId,
(_wid, payload) => {
for (const event of payload.events) {
(handler as (event: FsWatchEvent) => void)(event);
}
},
);
cleanups.push(removeListener);
bus.watchFs(workspaceId);
cleanups.push(() => bus.unwatchFs(workspaceId));
} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts`
around lines 44 - 55, The fs listener is registered after calling bus.watchFs
causing a race where events can be dropped; move the registration so
bus.on("fs:events", workspaceId, ...) is called before bus.watchFs(workspaceId),
push the returned removeListener into cleanups and only then call
bus.watchFs(workspaceId), and keep the unwatch cleanup
(bus.unwatchFs(workspaceId)) as before so removal order remains correct; update
the logic in useWorkspaceEvent to ensure registration (via bus.on) happens prior
to calling bus.watchFs.

const removeListener = bus.on("git:changed", workspaceId, () => {
(handler as () => void)();
});
cleanups.push(removeListener);
}

cleanups.push(bus.retain());

return () => {
for (const cleanup of cleanups) {
cleanup();
}
};
}, [enabled, hostUrl, type, workspaceId]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useWorkspaceHostUrl } from "./useWorkspaceHostUrl";
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useMemo } from "react";
import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider";

/**
* Resolves a workspace ID to its host-service URL.
* Local host → localhost port. Remote host → relay proxy URL.
*/
export function useWorkspaceHostUrl(workspaceId: string): string | null {
const collections = useCollections();
const { services } = useHostService();

const { data: workspaceWithHost = [] } = useLiveQuery(
(q) =>
q
.from({ workspaces: collections.v2Workspaces })
.innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) =>
eq(workspaces.hostId, hosts.id),
)
.where(({ workspaces }) => eq(workspaces.id, workspaceId))
.select(({ workspaces, hosts }) => ({
hostId: workspaces.hostId,
hostOrgId: hosts.organizationId,
})),
[collections, workspaceId],
);

const match = workspaceWithHost[0] ?? null;

return useMemo(() => {
if (!match) return null;
const localService = services.get(match.hostOrgId);
if (localService) return localService.url;
return getRemoteHostUrl(match.hostId);
}, [match, services]);
}
16 changes: 6 additions & 10 deletions apps/desktop/src/renderer/lib/v2-workspace-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@ import { env } from "renderer/env.renderer";
export type WorkspaceHostTarget =
| { kind: "local" }
| { kind: "cloud" }
| { kind: "device"; deviceId: string };
| { kind: "host"; hostId: string };

export function getCloudWorkspaceHostUrl(): string {
return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/cloud/host`;
return `${env.NEXT_PUBLIC_API_URL}/api/v2-hosts/cloud/trpc`;
}

export function getWorkspaceHostUrlForDevice(deviceId: string): string {
return `${env.NEXT_PUBLIC_API_URL}/api/v2-devices/${deviceId}/host`;
}

export function getWorkspaceHostUrlForWorkspace(workspaceId: string): string {
return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/${workspaceId}/host`;
export function getRemoteHostUrl(hostId: string): string {
return `${env.NEXT_PUBLIC_API_URL}/api/v2-hosts/${hostId}/trpc`;
}

export function resolveCreateWorkspaceHostUrl(
Expand All @@ -26,7 +22,7 @@ export function resolveCreateWorkspaceHostUrl(
return localHostUrl;
case "cloud":
return getCloudWorkspaceHostUrl();
case "device":
return getWorkspaceHostUrlForDevice(target.deviceId);
case "host":
return getRemoteHostUrl(target.hostId);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useDiffStats } from "renderer/hooks/host-service/useDiffStats";
import type { DashboardSidebarWorkspace } from "../../types";
import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog";
import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton";
import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSidebarExpandedWorkspaceRow";
import { DashboardSidebarWorkspaceContextMenu } from "./components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu";
import { DashboardSidebarWorkspaceHoverCardContent } from "./components/DashboardSidebarWorkspaceHoverCardContent";
import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSidebarWorkspaceItemActions";
import { getWorkspaceRowMocks } from "./utils";

interface DashboardSidebarWorkspaceItemProps {
workspace: DashboardSidebarWorkspace;
Expand All @@ -31,7 +31,7 @@ export function DashboardSidebarWorkspaceItem({
branch,
creationStatus,
} = workspace;
const mockData = getWorkspaceRowMocks(id);
const diffStats = useDiffStats(id);
const {
cancelRename,
handleClick,
Expand Down Expand Up @@ -73,7 +73,6 @@ export function DashboardSidebarWorkspaceItem({
hostType={hostType}
isActive={isActive}
onClick={isCreating ? undefined : handleClick}
workspaceStatus={isCreating ? null : mockData.workspaceStatus}
creationStatus={creationStatus}
disabled={isCreating}
aria-label={
Expand All @@ -97,7 +96,7 @@ export function DashboardSidebarWorkspaceItem({
hoverCardContent={
<DashboardSidebarWorkspaceHoverCardContent
workspace={workspace}
mockData={mockData}
diffStats={diffStats}
/>
}
onCreateSection={handleCreateSection}
Expand Down Expand Up @@ -135,7 +134,7 @@ export function DashboardSidebarWorkspaceItem({
isRenaming={isRenaming}
renameValue={renameValue}
shortcutLabel={shortcutLabel}
mockData={isCreating ? { ...mockData, workspaceStatus: null } : mockData}
diffStats={isCreating ? null : diffStats}
onClick={isCreating ? undefined : handleClick}
onDoubleClick={isCreating ? undefined : startRename}
onDeleteClick={() => setIsDeleteDialogOpen(true)}
Expand All @@ -159,7 +158,7 @@ export function DashboardSidebarWorkspaceItem({
hoverCardContent={
<DashboardSidebarWorkspaceHoverCardContent
workspace={workspace}
mockData={mockData}
diffStats={diffStats}
/>
}
onCreateSection={handleCreateSection}
Expand Down
Loading
Loading