Skip to content

Commit 1423710

Browse files
committed
feat(desktop): add per-project backend preference with switch prompt
Adds a desktop-side registry of cwd to preferred backend so users can pin specific project folders to Windows or WSL (with optional distro). When adding a pinned folder from the command palette, T3 Code prompts to swap backends first; existing projects with mismatched preferences surface a small indicator badge in the chat header with a manual switch button. * New DesktopProjectBackendPreferences Effect service backing project-backend-preferences.json with atomic writes plus lenient JSON, matching the DesktopSavedEnvironments pattern. * Extracts the WSL swap orchestration from ConnectionsSettings into a reusable performWslBackendSwap so the settings page, command palette prompt, and badge all share one path. * Centralizes the mismatch decision in resolveProjectBackendIntent so every UI surface uses the same rules. * Gates all new UI on wslState.available so non-WSL users see the same surface area they did before.
1 parent 7c9f3a4 commit 1423710

25 files changed

Lines changed: 2165 additions & 127 deletions

apps/desktop/src/app/DesktopEnvironment.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ describe("DesktopEnvironment", () => {
5959
assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json");
6060
assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json");
6161
assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
62+
assert.equal(
63+
environment.projectBackendPreferencesPath,
64+
"/tmp/t3/dev/project-backend-preferences.json",
65+
);
6266
assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
6367
assert.equal(environment.logDir, "/tmp/t3/dev/logs");
6468
assert.equal(environment.rootDir, "/repo");

apps/desktop/src/app/DesktopEnvironment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface DesktopEnvironmentShape {
4747
readonly desktopSettingsPath: string;
4848
readonly clientSettingsPath: string;
4949
readonly savedEnvironmentRegistryPath: string;
50+
readonly projectBackendPreferencesPath: string;
5051
readonly serverSettingsPath: string;
5152
readonly logDir: string;
5253
readonly rootDir: string;
@@ -181,6 +182,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
181182
desktopSettingsPath: path.join(stateDir, "desktop-settings.json"),
182183
clientSettingsPath: path.join(stateDir, "client-settings.json"),
183184
savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
185+
projectBackendPreferencesPath: path.join(stateDir, "project-backend-preferences.json"),
184186
serverSettingsPath: path.join(stateDir, "settings.json"),
185187
logDir: path.join(stateDir, "logs"),
186188
rootDir,

apps/desktop/src/ipc/DesktopIpcHandlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import * as Effect from "effect/Effect";
22

33
import * as DesktopIpc from "./DesktopIpc.ts";
44
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5+
import {
6+
clearProjectBackendPreference,
7+
getProjectBackendPreference,
8+
listProjectBackendPreferences,
9+
setProjectBackendPreference,
10+
} from "./methods/projectBackendPreferences.ts";
511
import {
612
getSavedEnvironmentRegistry,
713
getSavedEnvironmentSecret,
@@ -74,6 +80,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
7480
yield* ipc.handle(getWslState);
7581
yield* ipc.handle(setWslBackend);
7682

83+
yield* ipc.handle(getProjectBackendPreference);
84+
yield* ipc.handle(listProjectBackendPreferences);
85+
yield* ipc.handle(setProjectBackendPreference);
86+
yield* ipc.handle(clearProjectBackendPreference);
87+
7788
yield* ipc.handle(pickFolder);
7889
yield* ipc.handle(confirm);
7990
yield* ipc.handle(setTheme);

apps/desktop/src/ipc/channels.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-
3434
export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
3535
export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state";
3636
export const SET_WSL_BACKEND_CHANNEL = "desktop:set-wsl-backend";
37+
export const GET_PROJECT_BACKEND_PREFERENCE_CHANNEL = "desktop:get-project-backend-preference";
38+
export const LIST_PROJECT_BACKEND_PREFERENCES_CHANNEL = "desktop:list-project-backend-preferences";
39+
export const SET_PROJECT_BACKEND_PREFERENCE_CHANNEL = "desktop:set-project-backend-preference";
40+
export const CLEAR_PROJECT_BACKEND_PREFERENCE_CHANNEL = "desktop:clear-project-backend-preference";
3741
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
DesktopProjectBackendPreferenceInputSchema,
3+
DesktopProjectBackendPreferenceSchema,
4+
} from "@t3tools/contracts";
5+
import * as Effect from "effect/Effect";
6+
import * as Option from "effect/Option";
7+
import * as Schema from "effect/Schema";
8+
9+
import * as DesktopProjectBackendPreferences from "../../settings/DesktopProjectBackendPreferences.ts";
10+
import * as IpcChannels from "../channels.ts";
11+
import { makeIpcMethod } from "../DesktopIpc.ts";
12+
13+
const PreferencesArray = Schema.Array(DesktopProjectBackendPreferenceSchema);
14+
15+
export const getProjectBackendPreference = makeIpcMethod({
16+
channel: IpcChannels.GET_PROJECT_BACKEND_PREFERENCE_CHANNEL,
17+
payload: Schema.String,
18+
result: Schema.NullOr(DesktopProjectBackendPreferenceSchema),
19+
handler: Effect.fn("desktop.ipc.projectBackendPreferences.get")(function* (cwd) {
20+
const preferences = yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
21+
return Option.getOrNull(yield* preferences.get(cwd));
22+
}),
23+
});
24+
25+
export const listProjectBackendPreferences = makeIpcMethod({
26+
channel: IpcChannels.LIST_PROJECT_BACKEND_PREFERENCES_CHANNEL,
27+
payload: Schema.Void,
28+
result: PreferencesArray,
29+
handler: Effect.fn("desktop.ipc.projectBackendPreferences.list")(function* () {
30+
const preferences = yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
31+
return yield* preferences.list;
32+
}),
33+
});
34+
35+
export const setProjectBackendPreference = makeIpcMethod({
36+
channel: IpcChannels.SET_PROJECT_BACKEND_PREFERENCE_CHANNEL,
37+
payload: DesktopProjectBackendPreferenceInputSchema,
38+
result: DesktopProjectBackendPreferenceSchema,
39+
handler: Effect.fn("desktop.ipc.projectBackendPreferences.set")(function* (input) {
40+
const preferences = yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
41+
return yield* preferences.set(input);
42+
}),
43+
});
44+
45+
export const clearProjectBackendPreference = makeIpcMethod({
46+
channel: IpcChannels.CLEAR_PROJECT_BACKEND_PREFERENCE_CHANNEL,
47+
payload: Schema.String,
48+
result: Schema.Void,
49+
handler: Effect.fn("desktop.ipc.projectBackendPreferences.clear")(function* (cwd) {
50+
const preferences = yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
51+
yield* preferences.clear(cwd);
52+
}),
53+
});

apps/desktop/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
3535
import * as DesktopObservability from "./app/DesktopObservability.ts";
3636
import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts";
3737
import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
38+
import * as DesktopProjectBackendPreferences from "./settings/DesktopProjectBackendPreferences.ts";
3839
import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
3940
import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
4041
import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
@@ -113,6 +114,7 @@ const desktopFoundationLayer = Layer.mergeAll(
113114
DesktopAppSettings.layer,
114115
DesktopClientSettings.layer,
115116
DesktopSavedEnvironments.layer,
117+
DesktopProjectBackendPreferences.layer,
116118
DesktopAssets.layer,
117119
DesktopObservability.layer,
118120
).pipe(Layer.provideMerge(desktopEnvironmentLayer));

apps/desktop/src/preload.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
8989
getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL),
9090
getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL),
9191
setWslBackend: (input) => ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_CHANNEL, input),
92+
getProjectBackendPreference: (cwd) =>
93+
ipcRenderer.invoke(IpcChannels.GET_PROJECT_BACKEND_PREFERENCE_CHANNEL, cwd),
94+
listProjectBackendPreferences: () =>
95+
ipcRenderer.invoke(IpcChannels.LIST_PROJECT_BACKEND_PREFERENCES_CHANNEL),
96+
setProjectBackendPreference: (input) =>
97+
ipcRenderer.invoke(IpcChannels.SET_PROJECT_BACKEND_PREFERENCE_CHANNEL, input),
98+
clearProjectBackendPreference: (cwd) =>
99+
ipcRenderer.invoke(IpcChannels.CLEAR_PROJECT_BACKEND_PREFERENCE_CHANNEL, cwd),
92100
pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
93101
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
94102
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { assert, describe, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Layer from "effect/Layer";
6+
import * as Option from "effect/Option";
7+
8+
import * as DesktopConfig from "../app/DesktopConfig.ts";
9+
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
10+
import * as DesktopProjectBackendPreferences from "./DesktopProjectBackendPreferences.ts";
11+
12+
function makeLayer(baseDir: string) {
13+
const environmentLayer = DesktopEnvironment.layer({
14+
dirname: "/repo/apps/desktop/src",
15+
homeDirectory: baseDir,
16+
platform: "darwin",
17+
processArch: "x64",
18+
appVersion: "1.2.3",
19+
appPath: "/repo",
20+
isPackaged: true,
21+
resourcesPath: "/missing/resources",
22+
runningUnderArm64Translation: false,
23+
}).pipe(
24+
Layer.provide(
25+
Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
26+
),
27+
);
28+
29+
return DesktopProjectBackendPreferences.layer.pipe(
30+
Layer.provideMerge(environmentLayer),
31+
Layer.provideMerge(NodeServices.layer),
32+
);
33+
}
34+
35+
const withPreferences = <A, E, R>(
36+
effect: Effect.Effect<
37+
A,
38+
E,
39+
R | DesktopProjectBackendPreferences.DesktopProjectBackendPreferences
40+
>,
41+
) =>
42+
Effect.gen(function* () {
43+
const fileSystem = yield* FileSystem.FileSystem;
44+
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
45+
prefix: "t3-desktop-project-backend-preferences-test-",
46+
});
47+
return yield* effect.pipe(Effect.provide(makeLayer(baseDir)));
48+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
49+
50+
describe("DesktopProjectBackendPreferences", () => {
51+
it.effect("returns no preference for an unknown cwd", () =>
52+
withPreferences(
53+
Effect.gen(function* () {
54+
const preferences =
55+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
56+
assert.isTrue(Option.isNone(yield* preferences.get("/repo/unknown")));
57+
}),
58+
),
59+
);
60+
61+
it.effect("persists and reloads a wsl preference with a distro", () =>
62+
withPreferences(
63+
Effect.gen(function* () {
64+
const preferences =
65+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
66+
const saved = yield* preferences.set({
67+
cwd: "\\\\wsl.localhost\\Ubuntu\\home\\josh\\foo",
68+
preferredBackend: "wsl",
69+
wslDistro: "Ubuntu",
70+
});
71+
72+
assert.equal(saved.preferredBackend, "wsl");
73+
assert.equal(saved.wslDistro, "Ubuntu");
74+
// Stored key is normalized for case-insensitive Windows-shaped paths.
75+
assert.equal(saved.cwd, "\\\\wsl.localhost\\ubuntu\\home\\josh\\foo");
76+
77+
const fetched = yield* preferences.get("\\\\wsl.localhost\\UBUNTU\\HOME\\josh\\foo");
78+
assert.deepEqual(Option.getOrNull(fetched), saved);
79+
}),
80+
),
81+
);
82+
83+
it.effect("drops a wslDistro when the backend is not wsl", () =>
84+
withPreferences(
85+
Effect.gen(function* () {
86+
const preferences =
87+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
88+
const saved = yield* preferences.set({
89+
cwd: "C:\\Users\\Josh\\foo",
90+
preferredBackend: "windows",
91+
wslDistro: "Ubuntu",
92+
});
93+
assert.equal(saved.preferredBackend, "windows");
94+
assert.equal(saved.wslDistro, null);
95+
}),
96+
),
97+
);
98+
99+
it.effect("drops invalid distro names", () =>
100+
withPreferences(
101+
Effect.gen(function* () {
102+
const preferences =
103+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
104+
const saved = yield* preferences.set({
105+
cwd: "/home/josh/foo",
106+
preferredBackend: "wsl",
107+
// Trailing space / hyphen would have rejected by isValidDistroName.
108+
wslDistro: "Ubuntu ",
109+
});
110+
assert.equal(saved.wslDistro, null);
111+
}),
112+
),
113+
);
114+
115+
it.effect("upserts on the same cwd", () =>
116+
withPreferences(
117+
Effect.gen(function* () {
118+
const preferences =
119+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
120+
yield* preferences.set({
121+
cwd: "C:\\repo\\app",
122+
preferredBackend: "wsl",
123+
wslDistro: "Ubuntu",
124+
});
125+
yield* preferences.set({
126+
cwd: "c:/repo/app",
127+
preferredBackend: "windows",
128+
wslDistro: null,
129+
});
130+
131+
const all = yield* preferences.list;
132+
assert.lengthOf(all, 1);
133+
assert.equal(all[0]?.preferredBackend, "windows");
134+
assert.equal(all[0]?.cwd, "c:\\repo\\app");
135+
}),
136+
),
137+
);
138+
139+
it.effect("clears stored preferences for a cwd", () =>
140+
withPreferences(
141+
Effect.gen(function* () {
142+
const preferences =
143+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
144+
yield* preferences.set({
145+
cwd: "/home/josh/foo",
146+
preferredBackend: "wsl",
147+
wslDistro: "Ubuntu",
148+
});
149+
yield* preferences.clear("/home/josh/foo");
150+
assert.deepEqual(yield* preferences.list, []);
151+
}),
152+
),
153+
);
154+
155+
it.effect("ignores blank cwds for get/set/clear", () =>
156+
withPreferences(
157+
Effect.gen(function* () {
158+
const preferences =
159+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
160+
assert.isTrue(Option.isNone(yield* preferences.get(" ")));
161+
yield* preferences.clear("");
162+
assert.deepEqual(yield* preferences.list, []);
163+
}),
164+
),
165+
);
166+
167+
it.effect("treats malformed preference documents as empty", () =>
168+
withPreferences(
169+
Effect.gen(function* () {
170+
const environment = yield* DesktopEnvironment.DesktopEnvironment;
171+
const fileSystem = yield* FileSystem.FileSystem;
172+
yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
173+
yield* fileSystem.writeFileString(environment.projectBackendPreferencesPath, "{not-json");
174+
const preferences =
175+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
176+
assert.deepEqual(yield* preferences.list, []);
177+
}),
178+
),
179+
);
180+
181+
it.effect("rehydrates preferences across layer construction", () =>
182+
Effect.gen(function* () {
183+
const fileSystem = yield* FileSystem.FileSystem;
184+
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
185+
prefix: "t3-desktop-project-backend-preferences-test-",
186+
});
187+
188+
yield* Effect.gen(function* () {
189+
const preferences =
190+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
191+
yield* preferences.set({
192+
cwd: "/home/josh/persisted",
193+
preferredBackend: "wsl",
194+
wslDistro: "Ubuntu",
195+
});
196+
}).pipe(Effect.provide(makeLayer(baseDir)));
197+
198+
const list = yield* Effect.gen(function* () {
199+
const preferences =
200+
yield* DesktopProjectBackendPreferences.DesktopProjectBackendPreferences;
201+
return yield* preferences.list;
202+
}).pipe(Effect.provide(makeLayer(baseDir)));
203+
204+
assert.lengthOf(list, 1);
205+
assert.equal(list[0]?.cwd, "/home/josh/persisted");
206+
assert.equal(list[0]?.preferredBackend, "wsl");
207+
assert.equal(list[0]?.wslDistro, "Ubuntu");
208+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
209+
);
210+
});

0 commit comments

Comments
 (0)