Skip to content

Commit e7ca50d

Browse files
authored
feat: use shared_with_user filter for the shared workspaces view (#1026)
Query `shared_with_user:<user id>` server-side instead of fetching `shared:true` and filtering out own workspaces client-side, removing `excludeOwn`/`filterWorkspaces()`. Add viewsWelcome messaging for deployments where the server rejects the query (pre-2.27.0) instead of hiding the view, and give My Workspaces twice the initial sidebar size of the other views.
1 parent 6911779 commit e7ca50d

6 files changed

Lines changed: 83 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
bar item instead). A new **Coder: View Announcements** command opens the full
1616
messages in a markdown preview.
1717

18+
### Changed
19+
20+
- Filter the Shared Workspaces view with the server-side `shared_with_user`
21+
query instead of filtering `shared:true` results on the client, so fewer
22+
workspaces are fetched and the view loads faster. Deployments too old to
23+
support the new filter now show a message explaining why instead of an
24+
empty list.
25+
1826
### Fixed
1927

2028
- Apply a 60-second default timeout to REST requests, so requests hung on a
@@ -60,8 +68,6 @@
6068
`coder logout` rather than by the extension directly. The minimum supported
6169
Coder version is now v0.25.0.
6270

63-
### Changed
64-
6571
- Workspace opens and `coder://` URI handling now log more diagnostics (target
6672
workspace, agent, and handoff) to make failed opens easier to trace. URI
6773
parameter values, including tokens, are never logged.

package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,20 +316,23 @@
316316
"id": "myWorkspaces",
317317
"name": "My Workspaces",
318318
"visibility": "visible",
319-
"icon": "media/logo-white.svg"
319+
"icon": "media/logo-white.svg",
320+
"initialSize": 2
320321
},
321322
{
322323
"id": "sharedWorkspaces",
323324
"name": "Shared Workspaces",
324325
"visibility": "visible",
325326
"icon": "media/logo-white.svg",
327+
"initialSize": 1,
326328
"when": "coder.authenticated"
327329
},
328330
{
329331
"id": "allWorkspaces",
330332
"name": "All Workspaces",
331333
"visibility": "visible",
332334
"icon": "media/logo-white.svg",
335+
"initialSize": 1,
333336
"when": "coder.authenticated && coder.isOwner"
334337
}
335338
],
@@ -368,6 +371,16 @@
368371
"view": "coder.tasksLogin",
369372
"contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)",
370373
"when": "!coder.authenticated && coder.loaded"
374+
},
375+
{
376+
"view": "sharedWorkspaces",
377+
"contents": "Shared workspaces require Coder 2.27.0 or newer.",
378+
"when": "coder.authenticated && !coder.sharedWorkspacesSupported"
379+
},
380+
{
381+
"view": "sharedWorkspaces",
382+
"contents": "No workspaces have been shared with you.",
383+
"when": "coder.authenticated && coder.sharedWorkspacesSupported"
371384
}
372385
],
373386
"commands": [

src/core/contextManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = {
44
"coder.authenticated": false,
55
"coder.isOwner": false,
66
"coder.loaded": false,
7+
"coder.sharedWorkspacesSupported": true,
78
"coder.workspace.connected": false,
89
"coder.workspace.updatable": false,
910
"coder.workspacesPanelEnabled": false,

src/extension.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,23 @@ async function doActivate(
193193
client,
194194
output,
195195
deploymentManager.session,
196+
{
197+
// Older deployments reject this query; show a message instead of an
198+
// empty tree (see `viewsWelcome` in package.json).
199+
onQueryRejected: () =>
200+
contextManager.set("coder.sharedWorkspacesSupported", false),
201+
},
196202
);
197203
ctx.subscriptions.push(sharedWorkspacesProvider);
198204

205+
// Re-probe support on session change (login, logout, deployment switch);
206+
// the next fetch clears the message again if still unsupported.
207+
ctx.subscriptions.push(
208+
deploymentManager.session.onDidChange(() =>
209+
contextManager.set("coder.sharedWorkspacesSupported", true),
210+
),
211+
);
212+
199213
// createTreeView, unlike registerTreeDataProvider, gives us the tree view API
200214
// (so we can see when it is visible) but otherwise they have the same effect.
201215
const registerWorkspaceTreeView = (
@@ -328,6 +342,9 @@ async function doActivate(
328342
commands.navigateToWorkspaceSettings.bind(commands),
329343
);
330344
commandManager.register("coder.refreshWorkspaces", () => {
345+
// Re-probe in case the deployment was upgraded; the fetch below clears
346+
// the message again on rejection.
347+
contextManager.set("coder.sharedWorkspacesSupported", true);
331348
void myWorkspacesProvider.fetchAndRefresh();
332349
void sharedWorkspacesProvider.fetchAndRefresh();
333350
void allWorkspacesProvider.fetchAndRefresh();

src/workspace/workspacesProvider.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isAxiosError } from "axios";
12
import {
23
type Workspace,
34
type WorkspaceAgent,
@@ -23,40 +24,47 @@ import { type Logger } from "../logging/logger";
2324
import type { SessionData, SessionState } from "../deployment/sessionStore";
2425

2526
export enum WorkspaceQuery {
26-
Mine = "owner:me",
27-
All = "",
28-
Shared = "shared:true",
27+
Mine = "mine",
28+
All = "all",
29+
Shared = "shared",
2930
}
3031

31-
/** Per-view rendering behavior, keyed by workspace query. */
32+
type SignedInSession = Extract<SessionData, { kind: "signedIn" }>;
33+
34+
/** Per-view rendering behavior and search query, keyed by workspace view. */
3235
interface WorkspaceQueryConfig {
3336
readonly showOwner: boolean;
3437
readonly showMetadata: boolean;
35-
readonly excludeOwn: boolean;
38+
readonly getQuery: (session: SignedInSession) => string;
3639
}
3740

3841
const WORKSPACE_QUERY_CONFIG = {
3942
[WorkspaceQuery.Mine]: {
4043
showOwner: false,
4144
showMetadata: true,
42-
excludeOwn: false,
45+
getQuery: () => "owner:me",
4346
},
4447
[WorkspaceQuery.All]: {
4548
showOwner: true,
4649
showMetadata: false,
47-
excludeOwn: false,
50+
getQuery: () => "",
4851
},
4952
[WorkspaceQuery.Shared]: {
5053
showOwner: true,
5154
showMetadata: false,
52-
// `shared:true` also returns workspaces we own and shared out; exclude
53-
// them to leave only those shared with us.
54-
excludeOwn: true,
55+
// Only workspaces shared with the user; excludes workspaces the user
56+
// owns and shared with others. Requires Coder 2.27.0+.
57+
getQuery: (session) => `shared_with_user:${session.user.id}`,
5558
},
5659
} as const satisfies Record<WorkspaceQuery, WorkspaceQueryConfig>;
5760

5861
export interface WorkspaceProviderOptions {
5962
readonly refreshIntervalMs?: number;
63+
/**
64+
* Called when the server rejects the workspaces query with HTTP 400,
65+
* which indicates a deployment that does not support the query filter.
66+
*/
67+
readonly onQueryRejected?: () => void;
6068
}
6169

6270
/**
@@ -127,6 +135,9 @@ export class WorkspaceProvider
127135
this.logger.warn("Failed to fetch workspaces:", error);
128136
hadError = true;
129137
this.setWorkspaces([]);
138+
if (isAxiosError(error) && error.response?.status === 400) {
139+
this.options.onQueryRejected?.();
140+
}
130141
}
131142
} while (this.refetchPending && !this.disposed && this.visible);
132143
} finally {
@@ -163,14 +174,14 @@ export class WorkspaceProvider
163174
}
164175

165176
const resp = await this.client.getWorkspaces({
166-
q: this.getWorkspacesQuery,
177+
q: this.config.getQuery(session),
167178
});
168179

169180
if (this.sessionChangedSince(session)) {
170181
return null;
171182
}
172183

173-
const workspaces = this.filterWorkspaces(resp.workspaces, session);
184+
const workspaces = resp.workspaces;
174185
const oldWatcherIds = [...this.agentWatchers.keys()];
175186
const reusedWatcherIds: string[] = [];
176187

@@ -221,18 +232,6 @@ export class WorkspaceProvider
221232
return this.sessionState.current !== session;
222233
}
223234

224-
private filterWorkspaces(
225-
workspaces: readonly Workspace[],
226-
session: Extract<SessionData, { kind: "signedIn" }>,
227-
): readonly Workspace[] {
228-
if (!this.config.excludeOwn) {
229-
return workspaces;
230-
}
231-
return workspaces.filter(
232-
(workspace) => workspace.owner_id !== session.user.id,
233-
);
234-
}
235-
236235
/**
237236
* Either start or stop the refresh timer based on visibility.
238237
*

test/unit/workspace/workspacesProvider.test.ts

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function setup() {
3636
const session = new TestSessionStore();
3737
const makeProvider = (
3838
query: WorkspaceQuery,
39-
options?: { refreshIntervalMs?: number },
39+
options?: { refreshIntervalMs?: number; onQueryRejected?: () => void },
4040
): WorkspaceProvider =>
4141
new WorkspaceProvider(
4242
query,
@@ -138,7 +138,7 @@ describe("WorkspaceProvider", () => {
138138

139139
it.each([
140140
[WorkspaceQuery.Mine, "owner:me"],
141-
[WorkspaceQuery.Shared, "shared:true"],
141+
[WorkspaceQuery.Shared, `shared_with_user:${TEST_CURRENT_USER_ID}`],
142142
[WorkspaceQuery.All, ""],
143143
])("fetches %s with the expected query", async (query, expectedQuery) => {
144144
const { client, makeProvider } = setup();
@@ -189,48 +189,33 @@ describe("WorkspaceProvider", () => {
189189
},
190190
);
191191

192-
it("filters current-user-owned workspaces from shared results", async () => {
192+
it("reports a rejected query when the server responds with HTTP 400", async () => {
193193
const { client, makeProvider } = setup();
194-
client.respondOnce([
195-
workspace({
196-
id: "owned-shared-out",
197-
name: "owned",
198-
owner_id: TEST_CURRENT_USER_ID,
199-
owner_name: "current",
200-
}),
201-
workspace({
202-
id: "shared-with-me",
203-
name: "shared",
204-
owner_id: "alice-id",
205-
owner_name: "alice",
194+
const onQueryRejected = vi.fn();
195+
client.getWorkspaces.mockRejectedValueOnce(
196+
Object.assign(new Error("invalid query"), {
197+
isAxiosError: true,
198+
response: { status: 400 },
206199
}),
207-
]);
208-
const provider = makeProvider(WorkspaceQuery.Shared);
200+
);
201+
const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected });
209202

210203
await show(provider);
211204

212-
expect(await labels(provider)).toEqual(["alice / shared"]);
205+
expect(onQueryRejected).toHaveBeenCalledTimes(1);
206+
expect(await provider.getChildren()).toEqual([]);
213207
});
214208

215-
it.each([WorkspaceQuery.Mine, WorkspaceQuery.All])(
216-
"does not apply shared ownership filtering to %s",
217-
async (query) => {
218-
const { client, makeProvider } = setup();
219-
client.respondOnce([
220-
workspace({
221-
id: "owned",
222-
name: "owned",
223-
owner_id: TEST_CURRENT_USER_ID,
224-
owner_name: "current",
225-
}),
226-
]);
227-
const provider = makeProvider(query);
209+
it("does not report a rejected query for other fetch failures", async () => {
210+
const { client, makeProvider } = setup();
211+
const onQueryRejected = vi.fn();
212+
client.getWorkspaces.mockRejectedValueOnce(new Error("network down"));
213+
const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected });
228214

229-
await show(provider);
215+
await show(provider);
230216

231-
expect(await labels(provider)).toHaveLength(1);
232-
},
233-
);
217+
expect(onQueryRejected).not.toHaveBeenCalled();
218+
});
234219

235220
it("clears rendered workspaces when the session signs out", async () => {
236221
const { client, session, makeProvider } = setup();

0 commit comments

Comments
 (0)