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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
type HostProjectItem,
type HostProjectRow,
type HostProjectRowsResult,
type UseHostProjectsResult,
useHostProjects,
} from "./useHostProjects";
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getHostProjectsQueryKey,
type HostProjectItem,
type HostProjectRow,
type HostProjectRowsResult,
loadHostProjectsSnapshot,
mergeHostProjects,
normalizeHostProjectRow,
Expand All @@ -23,12 +24,15 @@ import {
export type {
HostProjectItem,
HostProjectRow,
HostProjectRowsResult,
} from "./useHostProjects.utils";

const PROJECTS_FALLBACK_REFETCH_INTERVAL_MS = 30_000;

export interface UseHostProjectsResult {
projects: HostProjectItem[];
/** Unmerged per-host rows for compatibility adapters. */
hostResults: HostProjectRowsResult[];
/**
* True once every host answered, failed, or served a snapshot. Gates
* empty states only — existing rows always render (cache-first rule).
Expand Down Expand Up @@ -189,21 +193,23 @@ export function useHostProjects(): UseHostProjectsResult {
};
}, [targets, queryClient]);

const projects = useMemo(
const hostResults = useMemo<HostProjectRowsResult[]>(
() =>
mergeHostProjects({
hostResults: targets.map((target, index) => {
const query = queries[index];
const live = query?.data;
return {
target,
rows: live ?? snapshots.get(target.machineId),
reachable: live !== undefined && !query?.isError,
};
}),
targets.map((target, index) => {
const query = queries[index];
const live = query?.data;
return {
target,
rows: live ?? snapshots.get(target.machineId),
reachable: live !== undefined && !query?.isError,
};
}),
[targets, queries, snapshots],
);
const projects = useMemo(
() => mergeHostProjects({ hostResults }),
[hostResults],
);

// Never vacuously ready: zero targets means host discovery hasn't run
// yet (cold start), not "no projects exist". Known-hosts settlement gates
Expand All @@ -219,5 +225,5 @@ export function useHostProjects(): UseHostProjectsResult {
snapshots.has(targets[index]?.machineId ?? ""),
);

return { projects, isReady };
return { projects, hostResults, isReady };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import {
applyProjectChangedEvent,
normalizeHostProjectRow,
} from "./useHostProjects.utils";

const tagSettings = [
{
tag: "api",
displayName: "API",
color: "#ff0000",
tabOrder: null,
},
];

describe("old-host tag settings compatibility", () => {
test("normalization preserves project.list tag settings", () => {
expect(
normalizeHostProjectRow({
id: "project",
repoPath: "/tmp/project",
tagSettings,
}).tagSettings,
).toEqual(tagSettings);
});

test("project events keep the last settings when a snapshot omits them", () => {
const existing = normalizeHostProjectRow({
id: "project",
repoPath: "/tmp/project",
tagSettings,
});
const next = applyProjectChangedEvent(
[existing],
{
eventType: "updated",
project: {
id: "project",
name: "Renamed",
repoPath: "/tmp/project",
repoOwner: null,
repoName: null,
repoUrl: null,
worktreeBaseDir: null,
icon: null,
color: null,
createdAt: 1,
updatedAt: 2,
},
},
"project",
);
expect(next?.[0]?.tagSettings).toEqual(tagSettings);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface HostProjectRow {
color: string | null;
createdAt: number;
updatedAt: number;
/** Absent when served by an older host. */
/** @deprecated Mixed-version fallback; canonical reads use tagFolders. */
tagSettings?: HostTagSetting[];
}

Expand Down Expand Up @@ -54,7 +54,7 @@ export interface HostProjectItem {
hostReachable: boolean;
createdAt: number;
updatedAt: number;
/** Tag-folder presentation (host-side); absent from older hosts. */
/** @deprecated Mixed-version fallback; canonical reads use tagFolders. */
tagSettings?: HostTagSetting[];
}

Expand All @@ -66,6 +66,13 @@ export interface HostProjectsQueryTarget {
isLocal: boolean;
}

/** One host's unmerged project rows, retained for host-specific adapters. */
export interface HostProjectRowsResult {
target: HostProjectsQueryTarget;
rows: HostProjectRow[] | undefined;
reachable: boolean;
}

export interface HostRowForTargets {
organizationId: string;
machineId: string;
Expand Down Expand Up @@ -156,6 +163,7 @@ export function normalizeHostProjectRow(
color: row.color ?? null,
createdAt: row.createdAt ?? 0,
updatedAt: row.updatedAt ?? row.createdAt ?? 0,
tagSettings: row.tagSettings,
};
}

Expand Down Expand Up @@ -261,8 +269,8 @@ export function applyProjectChangedEvent(
color: snapshot.color ?? null,
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
// Optional on the wire: an emitter without settings at hand (or an
// older host) omits it — keep the row's last known set.
// Old hosts publish settings on project snapshots. New hosts may retain
// the field as a compatibility adapter; omission keeps the last value.
tagSettings: snapshot.tagSettings ?? existing?.tagSettings,
};
if (!rows) return [nextRow];
Expand All @@ -280,11 +288,7 @@ export function applyProjectChangedEvent(
export function mergeHostProjects({
hostResults,
}: {
hostResults: Array<{
target: HostProjectsQueryTarget;
rows: HostProjectRow[] | undefined;
reachable: boolean;
}>;
hostResults: HostProjectRowsResult[];
}): HostProjectItem[] {
const byKey = new Map<string, HostProjectItem>();

Expand Down Expand Up @@ -331,10 +335,6 @@ export function mergeHostProjects({
existing.icon = row.icon;
existing.color = row.color;
}
// Tag settings: a replica that has them beats one that doesn't
// (older host); among replicas that do, the local host wins, same
// as icon/color. Mutations write to every serving host, so
// replicas stay aligned in practice.
if (
row.tagSettings !== undefined &&
(existing.tagSettings === undefined || result.target.isLocal)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
type UseHostTagFoldersResult,
useHostTagFolders,
} from "./useHostTagFolders";
export type {
HostTagFolderSetting,
HostTagFoldersResult,
} from "./useHostTagFolders.utils";
export { mergeHostTagFoldersWithLegacy } from "./useHostTagFolders.utils";
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { env } from "renderer/env.renderer";
import { useKnownHosts } from "renderer/hooks/known-hosts/useKnownHosts";
import { useRelayUrl } from "renderer/hooks/useRelayUrl";
import { getHostEventBus } from "renderer/lib/host-event-bus";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { MOCK_ORG_ID } from "shared/constants";
import { deriveHostProjectsQueryTargets } from "../useHostProjects/useHostProjects.utils";
import {
type HostTagFolderSetting,
type HostTagFoldersResult,
mergeHostTagFolders,
} from "./useHostTagFolders.utils";

const TAG_FOLDERS_FALLBACK_REFETCH_INTERVAL_MS = 60_000;

export interface UseHostTagFoldersResult {
tagFolders: HostTagFolderSetting[];
hostResults: HostTagFoldersResult[];
/** True once discovery settled and no reachable host read is pending. */
isReady: boolean;
}

/**
* The tag-folder read path: query each known host that can contribute sidebar
* workspaces, retain its readiness, and reconcile project scopes served by
* more than one host. Folders travel on their own channel rather than riding
* project snapshots, because the host-local Sessions lane has no project.
*
* Deliberately lighter than `useHostProjects`: no IndexedDB snapshot. These
* rows are presentation-only, so a folder that renders with its default name
* and colour for one paint is a non-event — unlike a missing project, which
* would empty the sidebar.
*/
export function useHostTagFolders(): UseHostTagFoldersResult {
const queryClient = useQueryClient();
const { activeHostUrl, machineId, activeOrganizationId } =
useLocalHostService();
const relayUrl = useRelayUrl();
const fallbackOrganizationId = env.SKIP_ENV_VALIDATION
? MOCK_ORG_ID
: (activeOrganizationId ?? null);
const { hosts, settled: knownHostsSettled } = useKnownHosts();

const targets = useMemo(
() =>
deriveHostProjectsQueryTargets({
activeHostUrl,
hosts,
machineId,
relayUrl,
fallbackOrganizationId,
}),
[activeHostUrl, hosts, machineId, relayUrl, fallbackOrganizationId],
);

const queryKeys = useMemo(
() =>
targets.map((target) => [
"host-tag-folders",
target.organizationId,
target.machineId,
]),
[targets],
);

const queries = useQueries({
queries: targets.map((target, index) => ({
queryKey: queryKeys[index] as string[],
enabled: target.hostUrl !== null,
refetchInterval: TAG_FOLDERS_FALLBACK_REFETCH_INTERVAL_MS,
// See useHostProjects: "online" networkMode would pause 127.0.0.1
// queries when navigator.onLine is false, defeating offline-first.
networkMode: "always" as const,
refetchIntervalInBackground: true,
retry: 1,
queryFn: async (): Promise<HostTagFolderSetting[]> => {
if (!target.hostUrl) return [];
const client = getHostServiceClientByUrl(target.hostUrl);
// Let failures remain failures. In particular, an old host with no
// tagFolders router must not look like a successful empty response:
// the migration uses per-host readiness before attempting writes.
return (await client.tagFolders.list.query()) as HostTagFolderSetting[];
},
})),
});

// Live updates: refetch the owning host on its own tag-folders:changed.
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [index, target] of targets.entries()) {
if (!target.hostUrl) continue;
const bus = getHostEventBus(target.hostUrl);
const key = queryKeys[index];
const removeListener = bus.on("tag-folders:changed", "*", () => {
void queryClient.invalidateQueries({ queryKey: key });
});
const releaseBus = bus.retain();
cleanups.push(() => {
removeListener();
releaseBus();
});
}
return () => {
for (const cleanup of cleanups) cleanup();
};
}, [targets, queryKeys, queryClient]);

const hostResults = useMemo<HostTagFoldersResult[]>(
() =>
targets.map((target, index) => {
const query = queries[index];
return {
target,
status:
target.hostUrl === null
? "offline"
: query?.isSuccess
? "ready"
: query?.isError
? "error"
: "pending",
settings: query?.data ?? [],
};
}),
[targets, queries],
);
const tagFolders = useMemo(
() => mergeHostTagFolders(hostResults),
[hostResults],
);
const isReady =
knownHostsSettled &&
targets.length > 0 &&
hostResults.every((result) => result.status !== "pending");

return { tagFolders, hostResults, isReady };
}
Loading
Loading