Skip to content
Open
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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
bar item instead). A new **Coder: View Announcements** command opens the full
messages in a markdown preview.

### Changed

- Filter the Shared Workspaces view with the server-side `shared_with_user`
query instead of filtering `shared:true` results on the client, so fewer
workspaces are fetched and the view loads faster. Deployments too old to
support the new filter now show a message explaining why instead of an
empty list.

### Fixed

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

### Changed

- Workspace opens and `coder://` URI handling now log more diagnostics (target
workspace, agent, and handoff) to make failed opens easier to trace. URI
parameter values, including tokens, are never logged.
Expand Down
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,20 +316,23 @@
"id": "myWorkspaces",
"name": "My Workspaces",
"visibility": "visible",
"icon": "media/logo-white.svg"
"icon": "media/logo-white.svg",
"initialSize": 2
Comment thread
EhabY marked this conversation as resolved.
},
{
"id": "sharedWorkspaces",
"name": "Shared Workspaces",
"visibility": "visible",
"icon": "media/logo-white.svg",
"initialSize": 1,
"when": "coder.authenticated"
},
{
"id": "allWorkspaces",
"name": "All Workspaces",
"visibility": "visible",
"icon": "media/logo-white.svg",
"initialSize": 1,
"when": "coder.authenticated && coder.isOwner"
}
],
Expand Down Expand Up @@ -368,6 +371,16 @@
"view": "coder.tasksLogin",
"contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)",
"when": "!coder.authenticated && coder.loaded"
},
{
"view": "sharedWorkspaces",
"contents": "Shared workspaces require Coder 2.27.0 or newer.",
"when": "coder.authenticated && !coder.sharedWorkspacesSupported"
},
{
"view": "sharedWorkspaces",
"contents": "No workspaces have been shared with you.",
"when": "coder.authenticated && coder.sharedWorkspacesSupported"
}
],
"commands": [
Expand Down
1 change: 1 addition & 0 deletions src/core/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = {
"coder.authenticated": false,
"coder.isOwner": false,
"coder.loaded": false,
"coder.sharedWorkspacesSupported": true,
"coder.workspace.connected": false,
"coder.workspace.updatable": false,
"coder.workspacesPanelEnabled": false,
Expand Down
17 changes: 17 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,23 @@ async function doActivate(
client,
output,
deploymentManager.session,
{
// Older deployments reject this query; show a message instead of an
// empty tree (see `viewsWelcome` in package.json).
onQueryRejected: () =>
contextManager.set("coder.sharedWorkspacesSupported", false),
},
);
ctx.subscriptions.push(sharedWorkspacesProvider);

// Re-probe support on session change (login, logout, deployment switch);
// the next fetch clears the message again if still unsupported.
ctx.subscriptions.push(
deploymentManager.session.onDidChange(() =>
contextManager.set("coder.sharedWorkspacesSupported", true),
),
);

// createTreeView, unlike registerTreeDataProvider, gives us the tree view API
// (so we can see when it is visible) but otherwise they have the same effect.
const registerWorkspaceTreeView = (
Expand Down Expand Up @@ -328,6 +342,9 @@ async function doActivate(
commands.navigateToWorkspaceSettings.bind(commands),
);
commandManager.register("coder.refreshWorkspaces", () => {
// Re-probe in case the deployment was upgraded; the fetch below clears
// the message again on rejection.
contextManager.set("coder.sharedWorkspacesSupported", true);
void myWorkspacesProvider.fetchAndRefresh();
void sharedWorkspacesProvider.fetchAndRefresh();
void allWorkspacesProvider.fetchAndRefresh();
Expand Down
47 changes: 23 additions & 24 deletions src/workspace/workspacesProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAxiosError } from "axios";
import {
type Workspace,
type WorkspaceAgent,
Expand All @@ -23,40 +24,47 @@ import { type Logger } from "../logging/logger";
import type { SessionData, SessionState } from "../deployment/sessionStore";

export enum WorkspaceQuery {
Mine = "owner:me",
All = "",
Shared = "shared:true",
Mine = "mine",
All = "all",
Shared = "shared",
}

/** Per-view rendering behavior, keyed by workspace query. */
type SignedInSession = Extract<SessionData, { kind: "signedIn" }>;

/** Per-view rendering behavior and search query, keyed by workspace view. */
interface WorkspaceQueryConfig {
readonly showOwner: boolean;
readonly showMetadata: boolean;
readonly excludeOwn: boolean;
readonly getQuery: (session: SignedInSession) => string;
}

const WORKSPACE_QUERY_CONFIG = {
[WorkspaceQuery.Mine]: {
showOwner: false,
showMetadata: true,
excludeOwn: false,
getQuery: () => "owner:me",
},
[WorkspaceQuery.All]: {
showOwner: true,
showMetadata: false,
excludeOwn: false,
getQuery: () => "",
},
[WorkspaceQuery.Shared]: {
showOwner: true,
showMetadata: false,
// `shared:true` also returns workspaces we own and shared out; exclude
// them to leave only those shared with us.
excludeOwn: true,
// Only workspaces shared with the user; excludes workspaces the user
// owns and shared with others. Requires Coder 2.27.0+.
getQuery: (session) => `shared_with_user:${session.user.id}`,
},
} as const satisfies Record<WorkspaceQuery, WorkspaceQueryConfig>;

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

/**
Expand Down Expand Up @@ -127,6 +135,9 @@ export class WorkspaceProvider
this.logger.warn("Failed to fetch workspaces:", error);
hadError = true;
this.setWorkspaces([]);
if (isAxiosError(error) && error.response?.status === 400) {
this.options.onQueryRejected?.();
}
}
} while (this.refetchPending && !this.disposed && this.visible);
} finally {
Expand Down Expand Up @@ -163,14 +174,14 @@ export class WorkspaceProvider
}

const resp = await this.client.getWorkspaces({
q: this.getWorkspacesQuery,
q: this.config.getQuery(session),
});

if (this.sessionChangedSince(session)) {
return null;
}

const workspaces = this.filterWorkspaces(resp.workspaces, session);
const workspaces = resp.workspaces;
const oldWatcherIds = [...this.agentWatchers.keys()];
const reusedWatcherIds: string[] = [];

Expand Down Expand Up @@ -221,18 +232,6 @@ export class WorkspaceProvider
return this.sessionState.current !== session;
}

private filterWorkspaces(
workspaces: readonly Workspace[],
session: Extract<SessionData, { kind: "signedIn" }>,
): readonly Workspace[] {
if (!this.config.excludeOwn) {
return workspaces;
}
return workspaces.filter(
(workspace) => workspace.owner_id !== session.user.id,
);
}

/**
* Either start or stop the refresh timer based on visibility.
*
Expand Down
55 changes: 20 additions & 35 deletions test/unit/workspace/workspacesProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function setup() {
const session = new TestSessionStore();
const makeProvider = (
query: WorkspaceQuery,
options?: { refreshIntervalMs?: number },
options?: { refreshIntervalMs?: number; onQueryRejected?: () => void },
): WorkspaceProvider =>
new WorkspaceProvider(
query,
Expand Down Expand Up @@ -138,7 +138,7 @@ describe("WorkspaceProvider", () => {

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

it("filters current-user-owned workspaces from shared results", async () => {
it("reports a rejected query when the server responds with HTTP 400", async () => {
const { client, makeProvider } = setup();
client.respondOnce([
workspace({
id: "owned-shared-out",
name: "owned",
owner_id: TEST_CURRENT_USER_ID,
owner_name: "current",
}),
workspace({
id: "shared-with-me",
name: "shared",
owner_id: "alice-id",
owner_name: "alice",
const onQueryRejected = vi.fn();
client.getWorkspaces.mockRejectedValueOnce(
Object.assign(new Error("invalid query"), {
isAxiosError: true,
response: { status: 400 },
}),
]);
const provider = makeProvider(WorkspaceQuery.Shared);
);
const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected });

await show(provider);

expect(await labels(provider)).toEqual(["alice / shared"]);
expect(onQueryRejected).toHaveBeenCalledTimes(1);
expect(await provider.getChildren()).toEqual([]);
});

it.each([WorkspaceQuery.Mine, WorkspaceQuery.All])(
"does not apply shared ownership filtering to %s",
async (query) => {
const { client, makeProvider } = setup();
client.respondOnce([
workspace({
id: "owned",
name: "owned",
owner_id: TEST_CURRENT_USER_ID,
owner_name: "current",
}),
]);
const provider = makeProvider(query);
it("does not report a rejected query for other fetch failures", async () => {
const { client, makeProvider } = setup();
const onQueryRejected = vi.fn();
client.getWorkspaces.mockRejectedValueOnce(new Error("network down"));
const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected });

await show(provider);
await show(provider);

expect(await labels(provider)).toHaveLength(1);
},
);
expect(onQueryRejected).not.toHaveBeenCalled();
});

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