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
11 changes: 7 additions & 4 deletions apps/web/src/components/settings/scopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function isConnectedWithConfig(
environment: Pick<ScopedSettingsEnvironment, "connection" | "serverConfig">,
) {
return environment.connection.phase === "connected" && environment.serverConfig !== null;
}

/** The representative supplies display values, never the set of write targets. */
export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>(
scope: ResolvedSettingsScope,
Expand All @@ -59,10 +65,7 @@ export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnviron
const environments = available.filter((environment) =>
selectedIds.has(environment.environmentId),
);
const connectedEnvironments = environments.filter(
(environment) =>
environment.connection.phase === "connected" && environment.serverConfig !== null,
);
const connectedEnvironments = environments.filter(isConnectedWithConfig);
const environment =
connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ??
connectedEnvironments[0] ??
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/state/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { EnvironmentId, type KeybindingRule, type ServerConfig } from "@t3tools/contracts";
import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings";
import { Atom, AtomRegistry } from "effect/reactivity";
import { afterEach, describe, expect, it } from "vite-plus/test";

import { resolveShortcutCommand } from "../keybindings";
import { createShortcutKeybindingsAtom } from "./server";

const PRIMARY = EnvironmentId.make("primary");
const A = EnvironmentId.make("a");
const B = EnvironmentId.make("b");

// The reporter's swap: Cmd+N opens a local thread instead of a regular one.
const SWAPPED: KeybindingRule[] = [{ key: "mod+n", command: "chat.newLocal" }];
const STALE: KeybindingRule[] = [{ key: "mod+n", command: "chat.newWithoutProject" }];

function config(rules: readonly KeybindingRule[]) {
return {
keybindings: compileResolvedKeybindingsConfig(rules),
providers: [],
} as unknown as ServerConfig;
}

function environment(phase: EnvironmentConnectionPhase, serverConfig: ServerConfig | null) {
return { connection: { phase, error: null, traceId: null }, serverConfig };
}

type Environment = ReturnType<typeof environment>;

function makeHarness(input: {
primaryEnvironmentId?: EnvironmentId | null;
primaryConfig?: ServerConfig | null;
environments?: ReadonlyArray<readonly [EnvironmentId, Environment]>;
}) {
const primaryEnvironmentIdAtom = Atom.make(input.primaryEnvironmentId ?? null);
const primaryConfigAtom = Atom.make(input.primaryConfig ?? null);
const environmentsAtom = Atom.make<ReadonlyMap<EnvironmentId, Environment>>(
new Map(input.environments ?? []),
);
const keybindingsAtom = createShortcutKeybindingsAtom({
primaryEnvironmentIdAtom,
primaryConfigAtom,
environmentsAtom,
});
const registry = AtomRegistry.make();
registries.push(registry);
// Keep the atom mounted, like the shortcut handlers do.
registry.subscribe(keybindingsAtom, () => {});
return {
registry,
primaryEnvironmentIdAtom,
primaryConfigAtom,
environmentsAtom,
keybindings: () => registry.get(keybindingsAtom),
cmdN: () =>
resolveShortcutCommand(
{ key: "n", metaKey: true, ctrlKey: false, shiftKey: false, altKey: false },
registry.get(keybindingsAtom),
{ platform: "MacIntel" },
),
};
}

const registries: AtomRegistry.AtomRegistry[] = [];
afterEach(() => {
for (const registry of registries.splice(0)) registry.dispose();
});

describe("createShortcutKeybindingsAtom", () => {
it("updates shortcuts from the first connected, configured environment without a primary", () => {
const harness = makeHarness({});
const aConfig = config(STALE);
const bConfig = config(SWAPPED);
const edited = config([{ key: "mod+n", command: "sidebar.toggle" }]);
expect(harness.cmdN()).toBe("chat.new");

// One mounted consumer follows config loading, edits, disconnects and reconnects.
for (const [a, b, command] of [
[environment("offline", aConfig), environment("connected", bConfig), "chat.newLocal"],
[environment("connected", null), environment("connected", bConfig), "chat.newLocal"],
[
environment("connected", aConfig),
environment("connected", bConfig),
"chat.newWithoutProject",
],
[environment("connected", edited), environment("connected", bConfig), "sidebar.toggle"],
[environment("offline", edited), environment("connected", bConfig), "chat.newLocal"],
[environment("offline", edited), environment("offline", bConfig), "chat.new"],
[environment("reconnecting", edited), environment("offline", bConfig), "chat.new"],
[
environment("connected", aConfig),
environment("offline", bConfig),
"chat.newWithoutProject",
],
] as const) {
harness.registry.set(
harness.environmentsAtom,
new Map([
[A, a],
[B, b],
]),
);
expect(harness.cmdN()).toBe(command);
}
});

it("uses the primary config or defaults until the primary is removed", () => {
const harness = makeHarness({
environments: [[B, environment("connected", config(SWAPPED))]],
});
expect(harness.cmdN()).toBe("chat.newLocal");

harness.registry.set(harness.primaryEnvironmentIdAtom, PRIMARY);
expect(harness.cmdN()).toBe("chat.new");

harness.registry.set(harness.primaryConfigAtom, config(STALE));
expect(harness.cmdN()).toBe("chat.newWithoutProject");

harness.registry.set(harness.primaryConfigAtom, null);
expect(harness.cmdN()).toBe("chat.new");

harness.registry.set(harness.primaryEnvironmentIdAtom, null);
expect(harness.cmdN()).toBe("chat.newLocal");
});

it.each([false, true])(
"preserves the bindings array through unrelated updates (primary: %s)",
(hasPrimary) => {
const selected = config(SWAPPED);
const harness = makeHarness({
primaryEnvironmentId: hasPrimary ? PRIMARY : null,
primaryConfig: hasPrimary ? selected : null,
environments: [
[A, environment("connected", selected)],
[B, environment("connected", config(STALE))],
],
});
expect(harness.cmdN()).toBe("chat.newLocal");
const before = harness.keybindings();

harness.registry.set(
harness.environmentsAtom,
new Map([
[A, environment("connected", { ...selected, providers: [] })],
[B, environment("connected", config([]))],
]),
);
expect(harness.keybindings()).toBe(before);

if (hasPrimary) {
harness.registry.set(harness.primaryConfigAtom, { ...selected, providers: [] });
expect(harness.keybindings()).toBe(before);
}
},
);
});
59 changes: 56 additions & 3 deletions apps/web/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import {
type ServerProvider,
type ServerSettings,
} from "@t3tools/contracts";
import {
AVAILABLE_CONNECTION_STATE,
presentEnvironmentConnection,
type EnvironmentPresentation,
} from "@t3tools/client-runtime/connection";
import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate";
import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";

import { isConnectedWithConfig } from "../components/settings/scopedSettings";
import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
import { environmentAtomList } from "./environmentAtomList";
Expand Down Expand Up @@ -109,9 +115,56 @@ export const primaryServerProvidersAtom = Atom.make(
get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,
).pipe(Atom.withLabel("web-primary-server-providers"));

export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] =>
mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []),
).pipe(Atom.withLabel("web-primary-server-keybindings"));
/** Without a primary, global shortcuts follow unscoped Settings. */
export function createShortcutKeybindingsAtom(input: {
readonly primaryEnvironmentIdAtom: Atom.Atom<EnvironmentId | null>;
readonly primaryConfigAtom: Atom.Atom<ServerConfig | null>;
readonly environmentsAtom: Atom.Atom<
ReadonlyMap<EnvironmentId, Pick<EnvironmentPresentation, "connection" | "serverConfig">>
>;
}) {
// Selected before merging, so unrelated config updates keep the merged array.
const customAtom = Atom.make((get) => {
if (get(input.primaryEnvironmentIdAtom) !== null) {
// A primary stays authoritative while its config loads.
return get(input.primaryConfigAtom)?.keybindings ?? null;
}
const environments = [...get(input.environmentsAtom).values()];
return environments.find(isConnectedWithConfig)?.serverConfig?.keybindings ?? null;
});
return Atom.make((get): ServerConfig["keybindings"] =>
mergeWithDefaultKeybindings(get(customAtom) ?? []),
);
}

// Settings' presentations import this module; mirror their catalog order and
// connection presentation here to avoid a cycle.
const environmentConnectionsAtom = Atom.make((get) => {
const environments = new Map<
EnvironmentId,
Pick<EnvironmentPresentation, "connection" | "serverConfig">
>();
for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) {
const state = Option.getOrElse(
AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))),
() => AVAILABLE_CONNECTION_STATE,
);
environments.set(environmentId, {
connection:
entry.unsupportedReason === undefined
? presentEnvironmentConnection(state)
: { phase: "unsupported", error: entry.unsupportedReason, traceId: null },
serverConfig: get(serverEnvironment.configValueAtom(environmentId)),
});
}
return environments;
});

export const primaryServerKeybindingsAtom = createShortcutKeybindingsAtom({
primaryEnvironmentIdAtom,
primaryConfigAtom: primaryServerConfigAtom,
environmentsAtom: environmentConnectionsAtom,
}).pipe(Atom.withLabel("web-primary-server-keybindings"));

export const primaryServerAvailableEditorsAtom = Atom.make(
(get): ReadonlyArray<EditorId> =>
Expand Down
Loading