feat: scope GitHub integration settings by workspace (#1572)

This commit is contained in:
Carlos Florêncio
2026-07-06 21:43:13 +01:00
committed by GitHub
parent a65df78bdd
commit c3f713e494
124 changed files with 6283 additions and 1177 deletions
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
import {
fetchGitHubWorkspaceSettings,
updateGitHubWorkspaceSettings,
} from "@/lib/api/domains/github-api";
import {
__resetSnapshotForTests,
useDefaultQueryPresets,
@@ -9,12 +13,19 @@ import {
const STORAGE_KEY = "kandev:github-default-queries:v1";
const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1";
const WORKSPACE_ID = "ws-1";
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchUserSettings: vi.fn(),
updateUserSettings: vi.fn(),
}));
vi.mock("@/lib/api/domains/github-api", () => ({
fetchGitHubWorkspaceSettings: vi.fn(),
updateGitHubWorkspaceSettings: vi.fn(),
}));
function makeLocalStorageMock() {
const store = new Map<string, string>();
return {
@@ -31,6 +42,7 @@ function makeLocalStorageMock() {
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", { value: localStorageMock, configurable: true });
const preset: StoredQueryPreset = {
value: "mine",
@@ -39,18 +51,39 @@ const preset: StoredQueryPreset = {
group: "created",
};
describe("useDefaultQueryPresets", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
__resetSnapshotForTests();
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { github_default_query_presets: null },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
vi.mocked(updateUserSettings).mockResolvedValue({
settings: {},
} as Awaited<ReturnType<typeof updateUserSettings>>);
});
function workspaceSettings(
defaultQueryPresets: unknown = null,
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
return {
workspace_id: WORKSPACE_ID,
repo_scope_mode: "all",
repo_scope_orgs: [],
repo_scope_repos: [],
saved_presets: [],
default_query_presets: defaultQueryPresets,
created_at: SETTINGS_TIMESTAMP,
updated_at: SETTINGS_TIMESTAMP,
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
}
function resetMocks() {
vi.clearAllMocks();
localStorageMock.clear();
__resetSnapshotForTests();
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { github_default_query_presets: null },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
vi.mocked(updateUserSettings).mockResolvedValue({
settings: {},
} as Awaited<ReturnType<typeof updateUserSettings>>);
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(
{} as Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>,
);
}
describe("useDefaultQueryPresets legacy sync", () => {
beforeEach(resetMocks);
it("retries local defaults after a failed backend sync and clears the marker", async () => {
const local = { pr: [preset], issue: [] };
@@ -115,3 +148,59 @@ describe("useDefaultQueryPresets", () => {
expect(updateUserSettings).not.toHaveBeenCalled();
});
});
describe("useDefaultQueryPresets workspace sync", () => {
beforeEach(resetMocks);
it("migrates local defaults into a fresh workspace", async () => {
const local = { pr: [preset], issue: [] };
localStorageMock.setItem(STORAGE_KEY, JSON.stringify(local));
__resetSnapshotForTests();
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
await waitFor(() => expect(result.current.prPresets[0]?.value).toBe("mine"));
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
workspace_id: WORKSPACE_ID,
default_query_presets: local,
});
});
it("does not migrate local defaults over existing workspace defaults", async () => {
const server = { pr: [{ ...preset, value: "server", label: "Server" }], issue: [] };
localStorageMock.setItem(STORAGE_KEY, JSON.stringify({ pr: [preset], issue: [] }));
__resetSnapshotForTests();
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings(server));
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
await waitFor(() => expect(result.current.prPresets[0]?.value).toBe("server"));
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not save or reset while workspace defaults are still loading", () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
act(() => {
result.current.save({ pr: [preset], issue: [] });
result.current.reset();
});
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not save or reset after workspace defaults fail to load", async () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
act(() => {
result.current.save({ pr: [preset], issue: [] });
result.current.reset();
});
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,22 @@
"use client";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
PR_PRESETS as BUILTIN_PR_PRESETS,
ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS,
type PresetOption,
} from "./search-bar";
import { fetchUserSettings } from "@/lib/api/domains/settings-api";
import {
fetchGitHubWorkspaceSettings,
updateGitHubWorkspaceSettings,
} from "@/lib/api/domains/github-api";
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure";
const STORAGE_KEY = "kandev:github-default-queries:v1";
const MIGRATED_KEY = "kandev:github-default-queries:migrated-to-backend:v1";
const WORKSPACE_MIGRATED_KEY_PREFIX = "kandev:github-default-queries:migrated-to-workspace:v1:";
const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1";
export type StoredQueryPreset = {
@@ -84,6 +89,16 @@ function readServerDefaults(value: unknown): StoredDefaults | null | undefined {
return value as StoredDefaults;
}
async function readLegacyServerDefaults(): Promise<StoredDefaults | null | undefined> {
try {
const response = await fetchUserSettings({ cache: "no-store" });
const defaults = readServerDefaults(response.settings.github_default_query_presets);
return defaults === undefined ? null : defaults;
} catch {
return undefined;
}
}
const syncServer = createQueuedUserSettingsSync<StoredDefaults | null>(
SYNC_FAILED_KEY,
(defaults) => ({
@@ -91,6 +106,23 @@ const syncServer = createQueuedUserSettingsSync<StoredDefaults | null>(
}),
);
let workspaceSyncQueue = Promise.resolve();
function syncWorkspaceDefaultQueryPresets(
workspaceId: string,
defaults: StoredDefaults | null,
): Promise<void> {
workspaceSyncQueue = workspaceSyncQueue
.catch(() => undefined)
.then(() =>
updateGitHubWorkspaceSettings({
workspace_id: workspaceId,
default_query_presets: defaults,
}).then(() => undefined),
);
return workspaceSyncQueue;
}
function snapshotKey(value: StoredDefaults | null): string {
return JSON.stringify(value);
}
@@ -109,6 +141,20 @@ function markMigratedToBackend(): void {
}
}
function hasMigratedToWorkspace(workspaceId: string): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId) === "1";
}
function markMigratedToWorkspace(workspaceId: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId, "1");
} catch {
/* ignore storage failures */
}
}
function subscribe(cb: () => void) {
listeners.add(cb);
const onStorage = (event: StorageEvent) => {
@@ -137,10 +183,9 @@ export function __resetSnapshotForTests() {
listeners.forEach((fn) => fn());
}
export function useDefaultQueryPresets() {
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
function useLegacyDefaultQueryPresetSync(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const initialKey = snapshotKey(getSnapshot());
fetchUserSettings({ cache: "no-store" })
@@ -165,24 +210,108 @@ export function useDefaultQueryPresets() {
return () => {
cancelled = true;
};
}, []);
}, [enabled]);
}
const prPresets = useMemo(() => stored?.pr ?? toStored(BUILTIN_PR_PRESETS), [stored]);
const issuePresets = useMemo(() => stored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS), [stored]);
const save = useCallback((defaults: StoredDefaults) => {
publish(defaults);
void syncServer(defaults);
markMigratedToBackend();
function useWorkspaceDefaultQueryPresets(workspaceId: string | null) {
const [workspaceDefaults, setWorkspaceDefaults] = useState<StoredDefaults | null | undefined>(
undefined,
);
const writeSeq = useRef(0);
useEffect(() => {
if (!workspaceId) {
setWorkspaceDefaults(undefined);
return;
}
let cancelled = false;
const seq = writeSeq.current;
setWorkspaceDefaults(undefined);
fetchGitHubWorkspaceSettings(workspaceId)
.then(async (settings) => {
if (cancelled || seq !== writeSeq.current) return;
const defaults = readServerDefaults(settings.default_query_presets);
let local: StoredDefaults | null | undefined = getSnapshot();
if (local === null) {
local = await readLegacyServerDefaults();
}
if (cancelled || seq !== writeSeq.current) return;
if (
(defaults === null || defaults === undefined) &&
local !== undefined &&
local !== null &&
!hasMigratedToWorkspace(workspaceId)
) {
setWorkspaceDefaults(local);
void syncWorkspaceDefaultQueryPresets(workspaceId, local)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return;
}
setWorkspaceDefaults(defaults === undefined ? null : defaults);
if (!(local === undefined && (defaults === null || defaults === undefined))) {
markMigratedToWorkspace(workspaceId);
}
})
.catch(() => {
if (!cancelled) setWorkspaceDefaults(undefined);
});
return () => {
cancelled = true;
};
}, [workspaceId]);
const setWorkspaceDefaultsFromLocal = useCallback((next: StoredDefaults | null) => {
writeSeq.current += 1;
setWorkspaceDefaults(next);
}, []);
return { workspaceDefaults, setWorkspaceDefaults: setWorkspaceDefaultsFromLocal };
}
export function useDefaultQueryPresets(workspaceId: string | null = null) {
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const { workspaceDefaults, setWorkspaceDefaults } = useWorkspaceDefaultQueryPresets(workspaceId);
useLegacyDefaultQueryPresetSync(!workspaceId);
const effectiveStored = workspaceId ? workspaceDefaults : stored;
const prPresets = useMemo(
() => effectiveStored?.pr ?? toStored(BUILTIN_PR_PRESETS),
[effectiveStored],
);
const issuePresets = useMemo(
() => effectiveStored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS),
[effectiveStored],
);
const save = useCallback(
(defaults: StoredDefaults) => {
if (workspaceId && workspaceDefaults === undefined) return;
if (workspaceId) {
setWorkspaceDefaults(defaults);
void syncWorkspaceDefaultQueryPresets(workspaceId, defaults)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return;
}
publish(defaults);
void syncServer(defaults);
markMigratedToBackend();
},
[workspaceId, workspaceDefaults, setWorkspaceDefaults],
);
const reset = useCallback(() => {
if (workspaceId && workspaceDefaults === undefined) return;
if (workspaceId) {
setWorkspaceDefaults(null);
void syncWorkspaceDefaultQueryPresets(workspaceId, null)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return;
}
publish(null);
void syncServer(null);
markMigratedToBackend();
}, []);
}, [workspaceId, workspaceDefaults, setWorkspaceDefaults]);
const isCustomized = stored !== null;
const isCustomized = effectiveStored !== null && effectiveStored !== undefined;
return { prPresets, issuePresets, save, reset, isCustomized };
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { searchUserPRs, searchUserIssues } from "@/lib/api/domains/github-api";
import type { GitHubPR, GitHubIssue } from "@/lib/types/github";
import type { PresetOption } from "./search-bar";
@@ -22,6 +22,16 @@ type FetchArgs = {
customQuery: string;
repoFilter: string;
page: number;
workspaceId: string | null;
};
type UseGitHubSearchOptions = {
kind: SearchKind;
presets: PresetOption[];
preset: string;
customQuery: string;
repoFilter?: string;
workspaceId?: string | null;
};
export function buildParams(
@@ -40,13 +50,14 @@ export function buildParams(
return { filter };
}
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
kind: SearchKind,
presets: PresetOption[],
preset: string,
customQuery: string,
repoFilter: string = "",
) {
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>({
kind,
presets,
preset,
customQuery,
repoFilter = "",
workspaceId = null,
}: UseGitHubSearchOptions) {
const [state, setState] = useState<SearchState<T>>({
items: [],
loading: false,
@@ -59,22 +70,33 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
// a slow page-N request can't overwrite a fresher page-1 result after a
// filter change.
const requestSeq = useRef(0);
const workspaceRef = useRef(workspaceId);
useLayoutEffect(() => {
workspaceRef.current = workspaceId;
}, [workspaceId]);
// Reset to page 1 whenever the filter inputs change.
useEffect(() => {
setPage(1);
}, [preset, customQuery, repoFilter, kind]);
}, [preset, customQuery, repoFilter, workspaceId, kind]);
const fetchData = useCallback(
async ({ preset: ep, customQuery: ec, repoFilter: er, page: epage }: FetchArgs) => {
async ({
preset: ep,
customQuery: ec,
repoFilter: er,
page: epage,
workspaceId: ews,
}: FetchArgs) => {
const seq = ++requestSeq.current;
setState((s) => ({ ...s, loading: true, error: null }));
try {
const base = buildParams(presets, ep, ec, er);
const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE };
const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE, workspaceId: ews };
const response =
kind === "pr" ? await searchUserPRs(params) : await searchUserIssues(params);
if (seq !== requestSeq.current) return;
if (seq !== requestSeq.current || ews !== workspaceRef.current) return;
const items = (kind === "pr"
? (response as { prs: GitHubPR[] }).prs
: (response as { issues: GitHubIssue[] }).issues) as unknown as T[];
@@ -86,7 +108,7 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
total: response.total_count ?? (items ?? []).length,
});
} catch (err) {
if (seq !== requestSeq.current) return;
if (seq !== requestSeq.current || ews !== workspaceRef.current) return;
setState((s) => ({
items: [],
loading: false,
@@ -100,12 +122,12 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
);
useEffect(() => {
void fetchData({ preset, customQuery, repoFilter, page });
}, [fetchData, preset, customQuery, repoFilter, page]);
void fetchData({ preset, customQuery, repoFilter, page, workspaceId });
}, [fetchData, preset, customQuery, repoFilter, page, workspaceId]);
const refresh = useCallback(
() => fetchData({ preset, customQuery, repoFilter, page }),
[fetchData, preset, customQuery, repoFilter, page],
() => fetchData({ preset, customQuery, repoFilter, page, workspaceId }),
[fetchData, preset, customQuery, repoFilter, page, workspaceId],
);
return { ...state, page, setPage, pageSize: SEARCH_PAGE_SIZE, refresh };
@@ -1,6 +1,10 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
import {
fetchGitHubWorkspaceSettings,
updateGitHubWorkspaceSettings,
} from "@/lib/api/domains/github-api";
import {
__resetSnapshotForTests,
readStorage,
@@ -10,12 +14,19 @@ import {
const STORAGE_KEY = "kandev:github-presets:v1";
const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1";
const WORKSPACE_ID = "ws-1";
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchUserSettings: vi.fn(),
updateUserSettings: vi.fn(),
}));
vi.mock("@/lib/api/domains/github-api", () => ({
fetchGitHubWorkspaceSettings: vi.fn(),
updateGitHubWorkspaceSettings: vi.fn(),
}));
// Provide a simple in-memory localStorage mock so the tests are not sensitive
// to how the test runner exposes window.localStorage (e.g. Node's
// --localstorage-file flag without a valid path).
@@ -35,6 +46,7 @@ function makeLocalStorageMock() {
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", { value: localStorageMock, configurable: true });
function set(raw: string | null) {
if (raw === null) localStorageMock.removeItem(STORAGE_KEY);
@@ -47,15 +59,36 @@ const valid: SavedPreset = {
label: "My PRs",
customQuery: "author:@me",
repoFilter: "",
createdAt: "2026-01-01T00:00:00Z",
createdAt: SETTINGS_TIMESTAMP,
};
function resetTestState() {
localStorageMock.clear();
__resetSnapshotForTests();
vi.mocked(fetchUserSettings).mockReset();
vi.mocked(updateUserSettings).mockReset();
vi.mocked(fetchGitHubWorkspaceSettings).mockReset();
vi.mocked(updateGitHubWorkspaceSettings).mockReset();
}
function workspaceSettings(
savedPresets: SavedPreset[] = [],
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
return {
workspace_id: WORKSPACE_ID,
repo_scope_mode: "all",
repo_scope_orgs: [],
repo_scope_repos: [],
saved_presets: savedPresets,
default_query_presets: null,
created_at: SETTINGS_TIMESTAMP,
updated_at: SETTINGS_TIMESTAMP,
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
}
describe("readStorage", () => {
beforeEach(() => {
localStorageMock.clear();
__resetSnapshotForTests();
vi.mocked(fetchUserSettings).mockReset();
vi.mocked(updateUserSettings).mockReset();
resetTestState();
});
it("returns empty array when no value is stored", () => {
@@ -108,10 +141,7 @@ describe("readStorage", () => {
describe("useSavedPresets", () => {
beforeEach(() => {
localStorageMock.clear();
__resetSnapshotForTests();
vi.mocked(fetchUserSettings).mockReset();
vi.mocked(updateUserSettings).mockReset();
resetTestState();
});
it("retries local presets after a failed backend sync and clears the marker", async () => {
@@ -150,3 +180,102 @@ describe("useSavedPresets", () => {
});
});
});
describe("useSavedPresets workspace sync", () => {
beforeEach(() => {
resetTestState();
});
it("migrates local presets into a fresh workspace", async () => {
set(JSON.stringify([valid]));
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(
{} as Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>,
);
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() => expect(result.current.presets).toEqual([valid]));
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
workspace_id: WORKSPACE_ID,
saved_presets: [valid],
});
});
it("does not migrate local presets over existing workspace presets", async () => {
const server = { ...valid, id: "p_server", label: "Server" };
set(JSON.stringify([valid]));
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([server]));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() => expect(result.current.presets).toEqual([server]));
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not save while workspace presets are still loading", () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
let created: SavedPreset | null = null;
act(() => {
created = result.current.save({
kind: "pr",
label: "Loading",
customQuery: "is:open",
repoFilter: "",
});
});
expect(created).toBeNull();
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not save after workspace presets fail to load", async () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
let created: SavedPreset | null = valid;
act(() => {
created = result.current.save({
kind: "pr",
label: "Failed load",
customQuery: "is:open",
repoFilter: "",
});
});
expect(created).toBeNull();
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not remove while workspace presets are still loading", () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
act(() => {
result.current.remove("p_1");
});
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
it("does not remove after workspace presets fail to load", async () => {
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
act(() => {
result.current.remove("p_1");
});
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,17 @@
"use client";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { fetchUserSettings } from "@/lib/api/domains/settings-api";
import {
fetchGitHubWorkspaceSettings,
updateGitHubWorkspaceSettings,
} from "@/lib/api/domains/github-api";
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure";
const STORAGE_KEY = "kandev:github-presets:v1";
const MIGRATED_KEY = "kandev:github-presets:migrated-to-backend:v1";
const WORKSPACE_MIGRATED_KEY_PREFIX = "kandev:github-presets:migrated-to-workspace:v1:";
const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1";
export type SavedPreset = {
@@ -86,10 +91,33 @@ function readServerPresets(value: unknown): SavedPreset[] | null {
);
}
async function readLegacyServerPresets(): Promise<SavedPreset[] | undefined> {
try {
const response = await fetchUserSettings({ cache: "no-store" });
return readServerPresets(response.settings.github_saved_presets) ?? [];
} catch {
return undefined;
}
}
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>(SYNC_FAILED_KEY, (next) => ({
github_saved_presets: next,
}));
let workspaceSyncQueue = Promise.resolve();
function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Promise<void> {
workspaceSyncQueue = workspaceSyncQueue
.catch(() => undefined)
.then(() =>
updateGitHubWorkspaceSettings({
workspace_id: workspaceId,
saved_presets: next,
}).then(() => undefined),
);
return workspaceSyncQueue;
}
function snapshotKey(value: SavedPreset[]): string {
return JSON.stringify(value);
}
@@ -108,15 +136,28 @@ function markMigratedToBackend(): void {
}
}
function hasMigratedToWorkspace(workspaceId: string): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId) === "1";
}
function markMigratedToWorkspace(workspaceId: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId, "1");
} catch {
/* ignore storage failures */
}
}
export function __resetSnapshotForTests() {
snapshot = null;
for (const l of listeners) l();
}
export function useSavedPresets() {
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
function useLegacySavedPresetsSync(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const initialKey = snapshotKey(readStorage());
fetchUserSettings({ cache: "no-store" })
@@ -141,27 +182,107 @@ export function useSavedPresets() {
return () => {
cancelled = true;
};
}, []);
const save = useCallback((input: Omit<SavedPreset, "id" | "createdAt">) => {
const preset: SavedPreset = {
...input,
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
const next = [...readStorage(), preset];
publish(next);
void syncServer(next);
markMigratedToBackend();
return preset;
}, []);
const remove = useCallback((id: string) => {
const next = readStorage().filter((p) => p.id !== id);
publish(next);
void syncServer(next);
markMigratedToBackend();
}, []);
return { presets, save, remove };
}, [enabled]);
}
function useWorkspaceSavedPresets(workspaceId: string | null) {
const [workspacePresets, setWorkspacePresets] = useState<SavedPreset[] | undefined>(undefined);
const writeSeq = useRef(0);
useEffect(() => {
if (!workspaceId) {
setWorkspacePresets(undefined);
return;
}
let cancelled = false;
const seq = writeSeq.current;
setWorkspacePresets(undefined);
fetchGitHubWorkspaceSettings(workspaceId)
.then(async (settings) => {
if (cancelled || seq !== writeSeq.current) return;
const serverPresets = readServerPresets(settings.saved_presets) ?? [];
let local: SavedPreset[] | undefined = readStorage();
if (local.length === 0) {
local = await readLegacyServerPresets();
}
if (cancelled || seq !== writeSeq.current) return;
if (
serverPresets.length === 0 &&
local !== undefined &&
local.length > 0 &&
!hasMigratedToWorkspace(workspaceId)
) {
setWorkspacePresets(local);
void syncWorkspaceSavedPresets(workspaceId, local)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return;
}
setWorkspacePresets(serverPresets);
if (local !== undefined || serverPresets.length > 0) {
markMigratedToWorkspace(workspaceId);
}
})
.catch(() => {
if (!cancelled) setWorkspacePresets(undefined);
});
return () => {
cancelled = true;
};
}, [workspaceId]);
const setWorkspacePresetsFromLocal = useCallback((next: SavedPreset[]) => {
writeSeq.current += 1;
setWorkspacePresets(next);
}, []);
return { workspacePresets, setWorkspacePresets: setWorkspacePresetsFromLocal };
}
export function useSavedPresets(workspaceId: string | null = null) {
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const { workspacePresets, setWorkspacePresets } = useWorkspaceSavedPresets(workspaceId);
useLegacySavedPresetsSync(!workspaceId);
const activePresets = workspaceId ? (workspacePresets ?? []) : presets;
const save = useCallback(
(input: Omit<SavedPreset, "id" | "createdAt">) => {
if (workspaceId && workspacePresets === undefined) return null;
const preset: SavedPreset = {
...input,
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
const next = [...activePresets, preset];
if (workspaceId) {
setWorkspacePresets(next);
void syncWorkspaceSavedPresets(workspaceId, next)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return preset;
}
publish(next);
void syncServer(next);
markMigratedToBackend();
return preset;
},
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
);
const remove = useCallback(
(id: string) => {
if (workspaceId && workspacePresets === undefined) return;
const next = activePresets.filter((p) => p.id !== id);
if (workspaceId) {
setWorkspacePresets(next);
void syncWorkspaceSavedPresets(workspaceId, next)
.then(() => markMigratedToWorkspace(workspaceId))
.catch(() => {});
return;
}
publish(next);
void syncServer(next);
markMigratedToBackend();
},
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
);
return { presets: activePresets, save, remove };
}