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
8 changes: 8 additions & 0 deletions apps/mobile/src/state/use-remote-environment-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({
serverConfigAtom: serverEnvironment.configValueAtom,
});

export const environmentConnectedAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make(
(get) =>
get(remoteEnvironmentProjections.runtimeStateAtom(environmentId))?.connectionState ===
"connected",
).pipe(Atom.withLabel(`mobile:environment-connected:${environmentId}`)),
);

const EMPTY_SAVED_CONNECTION_ATOM = Atom.make<SavedRemoteConnection | null>(null).pipe(
Atom.withLabel("mobile:saved-connection:empty"),
);
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/state/use-selected-thread-git-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ const state = vi.hoisted(() => ({
vi.mock("react", () => ({
useCallback: (callback: unknown) => callback,
useMemo: (factory: () => unknown) => factory(),
useEffect: () => {},
useEffectEvent: (callback: unknown) => callback,
}));
vi.mock("@effect/atom-react", () => ({ useAtomMount: () => {} }));
vi.mock("./session", () => ({
useEnvironmentScope: (environmentId: unknown, scope: string) =>
(environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope),
Expand All @@ -51,7 +50,10 @@ vi.mock("./queries", () => ({
}));
vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} }));
vi.mock("./use-remote-environment-registry", () => ({
environmentConnectedAtom: () => null,
setPendingConnectionError: () => {},
}));
vi.mock("./use-vcs-action-state", () => ({
showGitActionResult: (result: { type: string; description?: string }) =>
state.results.push(result),
Expand Down
113 changes: 0 additions & 113 deletions apps/mobile/src/state/use-selected-thread-git-actions.test.tsx

This file was deleted.

65 changes: 32 additions & 33 deletions apps/mobile/src/state/use-selected-thread-git-actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useEffectEvent, useMemo } from "react";
import { useAtomMount } from "@effect/atom-react";
import { useCallback, useMemo } from "react";

import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import {
createVcsStatusRevalidationAtomFamily,
type GitActionRequestInput,
type VcsActionOperation,
type VcsRef,
Expand All @@ -26,12 +28,31 @@ import { vcsActionManager, vcsEnvironment } from "../state/vcs";
import { uuidv4 } from "../lib/uuid";
import { appAtomRegistry } from "./atom-registry";
import { readEnvironmentScope, useEnvironmentScope } from "./session";
import { setPendingConnectionError } from "./use-remote-environment-registry";
import {
environmentConnectedAtom,
setPendingConnectionError,
} from "./use-remote-environment-registry";
import { useAtomCommand } from "./use-atom-command";
import { showGitActionResult } from "./use-vcs-action-state";
import { useThreadSelection } from "./use-thread-selection";
import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";

function refreshFailureMessage(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
return error instanceof Error ? error.message : "Failed to refresh git status.";
}

// Selecting a thread or reconnecting its environment refreshes the checkout's
// status once, shared by every sheet that shows it.
const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({
refreshStatus: vcsEnvironment.refreshStatus,
connected: environmentConnectedAtom,
onRefreshed: (result) =>
setPendingConnectionError(
AsyncResult.isFailure(result) ? refreshFailureMessage(result.cause) : null,
),
});

export function useSelectedThreadGitActions() {
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
Expand All @@ -41,8 +62,7 @@ export function useSelectedThreadGitActions() {
const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false });
const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } =
useThreadSelection();
const { selectedThread, selectedThreadProject } = useThreadSelection();
const canWriteSourceControl = useEnvironmentScope(
selectedThread?.environmentId ?? null,
AuthSourceControlWriteScope,
Expand Down Expand Up @@ -130,9 +150,7 @@ export function useSelectedThreadGitActions() {
execute,
);
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
const message = error instanceof Error ? error.message : "Failed to refresh git status.";
setPendingConnectionError(message);
setPendingConnectionError(refreshFailureMessage(result.cause));
return null;
}
setPendingConnectionError(null);
Expand All @@ -141,32 +159,13 @@ export function useSelectedThreadGitActions() {
[refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject],
);

// Key refreshes on primitives; streaming frequently replaces the selected thread object.
const selectedEnvironmentId = selectedThread?.environmentId ?? null;
const selectedThreadId = selectedThread?.id ?? null;
const hasSelectedThreadProject = selectedThreadProject !== null;
const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected";
const refreshOnSelection = useEffectEvent(() => {
void refreshSelectedThreadGitStatus({ quiet: true });
});
useEffect(() => {
if (
selectedEnvironmentId === null ||
selectedThreadId === null ||
!hasSelectedThreadProject ||
selectedThreadCwd === null ||
!isEnvironmentConnected
) {
return;
}
refreshOnSelection();
}, [
selectedEnvironmentId,
selectedThreadId,
hasSelectedThreadProject,
selectedThreadCwd,
isEnvironmentConnected,
]);
useAtomMount(
selectedThreadStatusRevalidation(
selectedThread && selectedThreadProject && selectedThreadCwd
? { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd }
: null,
),
);

const runSelectedThreadGitMutation = useCallback(
async <T, E>(
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,4 @@ export function createVcsEnvironmentAtoms<R, E>(
export * from "./gitActions.ts";
export * from "./vcsAction.ts";
export * from "./vcsRef.ts";
export * from "./vcsStatusRevalidation.ts";
93 changes: 93 additions & 0 deletions packages/client-runtime/src/state/vcsStatusRevalidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { EnvironmentId } from "@t3tools/contracts";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

import { createVcsStatusRevalidationAtomFamily } from "./vcsStatusRevalidation.ts";

const environmentId = EnvironmentId.make("environment-1");

function setup(options: { readonly connected?: boolean; readonly wake?: boolean }) {
const connectedAtom = Atom.make(options.connected ?? false);
const wakes = new Set<() => void>();
const run = vi.fn(async () => AsyncResult.success(null));
const family = createVcsStatusRevalidationAtomFamily({
refreshStatus: { label: "refresh-status", run },
...(options.connected === undefined ? {} : { connected: () => connectedAtom }),
...(options.wake
? {
subscribeWake: (wake: () => void) => {
wakes.add(wake);
return () => wakes.delete(wake);
},
wakeDebounceMs: 250,
}
: {}),
});
const registry = AtomRegistry.make();
const wake = () => {
for (const listener of wakes) listener();
};
return { registry, family, connectedAtom, run, wakes, wake };
}

describe("createVcsStatusRevalidationAtomFamily", () => {
it("refreshes once per connection, shared by every subscriber, and again on reconnect", () => {
const { registry, family, connectedAtom, run } = setup({ connected: true });
const target = { environmentId, cwd: "/repo" };
const first = registry.mount(family(target));
const second = registry.mount(family({ environmentId, cwd: "/repo" }));
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(expect.anything(), { environmentId, input: { cwd: "/repo" } });

registry.set(connectedAtom, false);
expect(run).toHaveBeenCalledTimes(1);
registry.set(connectedAtom, true);
expect(run).toHaveBeenCalledTimes(2);

registry.mount(family({ environmentId, cwd: "/other" }))();
expect(run).toHaveBeenCalledTimes(3);
first();
second();
});

it("waits for the connection before the first refresh", () => {
const { registry, family, connectedAtom, run } = setup({ connected: false });
const unmount = registry.mount(family({ environmentId, cwd: "/repo" }));
expect(run).not.toHaveBeenCalled();
registry.set(connectedAtom, true);
expect(run).toHaveBeenCalledTimes(1);
unmount();
});

it("debounces wake signals and stops listening with the last subscriber", () => {
vi.useFakeTimers();
try {
const { registry, family, run, wakes, wake } = setup({ wake: true });
const unmount = registry.mount(family({ environmentId, cwd: "/repo" }));
expect(run).not.toHaveBeenCalled();

wake();
vi.advanceTimersByTime(100);
wake();
vi.advanceTimersByTime(249);
expect(run).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(run).toHaveBeenCalledTimes(1);

wake();
unmount();
vi.runAllTimers();
expect(run).toHaveBeenCalledTimes(1);
expect(wakes.size).toBe(0);
} finally {
vi.useRealTimers();
}
});

it("does nothing for an idle target", () => {
const { registry, family, run, wakes } = setup({ connected: true, wake: true });
registry.mount(family(null))();
expect(run).not.toHaveBeenCalled();
expect(wakes.size).toBe(0);
});
});
Loading
Loading