Files
coder/site/src/modules/apps/useAppLink.ts
T
Jake HowellandKayla はな 15a2bab1cd feat: migrate from <GlobalSnackbar /> to sonner (#22258)
Replaces our custom `<GlobalSnackbar />` (MUI Snackbar + event emitter)
with [`sonner`](https://github.com/emilkowalski/sonner). Deletes
`GlobalSnackbar/`, the custom event emitter infra, and migrates ~80
source files to `toast.success()` / `toast.error()` from `sonner`.

- ~47 error toasts now surface API error detail via
`getErrorDetail(error)` in the toast description, not just a generic
message. Coincides with #22229.
- Toast messages follow an `{Action} "{entity}" {result}.` format (e.g.
`User "alice" suspended successfully.`) since toasts persist across
navigation now.
- 17 uses of `toast.promise()` for loading → success → error lifecycle.
- Some toasts include action buttons for quick navigation (e.g. "View
task", "View template").
- Multiple toasts can stack and display simultaneously.

---------

Co-authored-by: Kayla はな <mckayla@hey.com>
2026-02-26 02:42:34 +11:00

117 lines
3.2 KiB
TypeScript

import { apiKey } from "api/queries/users";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import type React from "react";
import { useQuery } from "react-query";
import { toast } from "sonner";
import {
getAppHref,
isExternalApp,
needsSessionToken,
openAppInNewWindow,
} from "./apps";
type UseAppLinkParams = {
workspace: Workspace;
agent: WorkspaceAgent;
};
type AppLink = {
href: string;
onClick: (e: React.MouseEvent) => void;
label: string;
hasToken: boolean;
};
export const useAppLink = (
app: WorkspaceApp,
{ agent, workspace }: UseAppLinkParams,
): AppLink => {
const label = app.display_name ?? app.slug;
const { proxy } = useProxy();
const { data: apiKeyResponse } = useQuery({
...apiKey(),
enabled: isExternalApp(app) && needsSessionToken(app),
});
const href = getAppHref(app, {
agent,
workspace,
token: apiKeyResponse?.key,
path: proxy.preferredPathAppURL,
host: proxy.preferredWildcardHostname,
});
const onClick = (e: React.MouseEvent) => {
if (!e.currentTarget.getAttribute("href")) {
return;
}
// External apps with custom protocols (non-HTTP) need special handling
// for error detection when the app isn't installed.
const isExternalProtocolApp =
app.external && app.url && !app.url.startsWith("http");
if (isExternalProtocolApp) {
// When browser recognizes the protocol and is able to navigate to the app,
// it will blur away, and will stop the timer. Otherwise,
// an error message will be displayed.
const openAppExternallyFailedTimeout = 1500;
const openAppExternallyFailed = setTimeout(() => {
// Check if this is a JetBrains IDE app
// starts with "jetbrains-gateway://connect#type=coder" (from https://registry.coder.com/modules/coder/jetbrains-gateway)
const isJetBrainsGateway = app.url?.startsWith("jetbrains-gateway:");
// starts with "jetbrains://gateway/coder" (from https://registry.coder.com/modules/coder/jetbrains)
const isJetBrainsToolbox = app.url?.startsWith("jetbrains:");
// Check if this is a coder:// URL
const isCoderApp = app.url?.startsWith("coder:");
if (isJetBrainsGateway) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Gateway must be installed.",
});
} else if (isJetBrainsToolbox) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Toolbox must be installed.",
});
} else if (isCoderApp) {
toast.error(`Failed to open "${label}".`, {
description: "Coder Desktop must be installed.",
});
} else {
toast.error(`Failed to open "${label}".`, {
description: "The app must be installed first.",
});
}
}, openAppExternallyFailedTimeout);
window.addEventListener("blur", () => {
clearTimeout(openAppExternallyFailed);
});
// Custom protocol external apps don't support open_in since they
// rely on the browser's protocol handling.
return;
}
switch (app.open_in) {
case "slim-window": {
e.preventDefault();
openAppInNewWindow(href);
return;
}
}
};
return {
href,
onClick,
label,
hasToken: !!apiKeyResponse?.key,
};
};