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
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "streamdown/styles.css";
@import "./styles/fade-edge.css";

@source "./**/*.{ts,tsx}";
@source "../../../../packages/ui/src/**/*.{ts,tsx}";
Expand Down
105 changes: 105 additions & 0 deletions apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it, mock } from "bun:test";
import type { ILinkProvider, Terminal as XTerm } from "@xterm/xterm";
import { TerminalLinkManager } from "./terminal-link-manager";

function createMockTerminal() {
const registeredProviders: ILinkProvider[] = [];
const disposedProviders: ILinkProvider[] = [];
const terminal = {
options: {
linkHandler: null,
},
registerLinkProvider: (provider: ILinkProvider) => {
registeredProviders.push(provider);
return {
dispose: () => {
disposedProviders.push(provider);
},
};
},
buffer: {
active: {
getLine: () => null,
},
},
cols: 80,
} as unknown as XTerm;

return { terminal, registeredProviders, disposedProviders };
}

describe("TerminalLinkManager", () => {
it("routes OSC 8 hyperlinks through the terminal URL handler", () => {
const { terminal } = createMockTerminal();
const manager = new TerminalLinkManager(terminal);
const onUrlClick = mock();
const onLinkHover = mock();
const onLinkLeave = mock();

manager.setHandlers({
stat: async () => null,
onUrlClick,
onLinkHover,
onLinkLeave,
});

const linkHandler = terminal.options.linkHandler;
expect(linkHandler).toBeTruthy();
expect(linkHandler?.allowNonHttpProtocols).toBe(false);

const event = {} as MouseEvent;
linkHandler?.activate(event, "https://example.com", {
start: { x: 1, y: 1 },
end: { x: 20, y: 1 },
});
linkHandler?.hover?.(event, "https://example.com", {
start: { x: 1, y: 1 },
end: { x: 20, y: 1 },
});
linkHandler?.leave?.(event, "https://example.com", {
start: { x: 1, y: 1 },
end: { x: 20, y: 1 },
});

expect(onUrlClick).toHaveBeenCalledWith(event, "https://example.com");
expect(onLinkHover).toHaveBeenCalledWith(event, { kind: "url" });
expect(onLinkLeave).toHaveBeenCalled();
});

it("clears only the OSC link handler it installed", () => {
const { terminal, disposedProviders } = createMockTerminal();
const manager = new TerminalLinkManager(terminal);

manager.setHandlers({
stat: async () => null,
onUrlClick: mock(),
});

const installedHandler = terminal.options.linkHandler;
expect(installedHandler).toBeTruthy();

manager.dispose();

expect(terminal.options.linkHandler).toBeNull();
expect(disposedProviders.length).toBe(2);
});

it("does not clear a link handler installed by another owner", () => {
const { terminal } = createMockTerminal();
const manager = new TerminalLinkManager(terminal);

manager.setHandlers({
stat: async () => null,
onUrlClick: mock(),
});

const replacementHandler = {
activate: mock(),
};
terminal.options.linkHandler = replacementHandler;

manager.dispose();

expect(terminal.options.linkHandler).toBe(replacementHandler);
});
});
27 changes: 26 additions & 1 deletion apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* resolver caching, and priority ordering.
*--------------------------------------------------------------------------------------------*/

import type { Terminal as XTerm } from "@xterm/xterm";
import type { ILinkHandler, Terminal as XTerm } from "@xterm/xterm";
import { UrlLinkProvider } from "../../screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers";
import type { DetectedLink } from "./links";
import {
Expand Down Expand Up @@ -57,6 +57,7 @@ export class TerminalLinkManager {
private _disposables: LinkProviderDisposable[] = [];
private _resolver: TerminalLinkResolver | null = null;
private _handlers: TerminalLinkHandlers | null = null;
private _oscLinkHandler: ILinkHandler | null = null;

constructor(private readonly _terminal: XTerm) {}

Expand All @@ -83,18 +84,27 @@ export class TerminalLinkManager {
dispose(): void {
for (const d of this._disposables) d.dispose();
this._disposables = [];
this._clearOscLinkHandler();
this._resolver?.clearCache();
this._resolver = null;
this._handlers = null;
}

private _clearOscLinkHandler(): void {
if (this._terminal.options.linkHandler === this._oscLinkHandler) {
this._terminal.options.linkHandler = null;
}
this._oscLinkHandler = null;
}

private _register(): void {
const handlers = this._handlers;
if (!handlers?.stat) return;

// Dispose old providers to prevent duplicates
for (const d of this._disposables) d.dispose();
this._disposables = [];
this._clearOscLinkHandler();

// Reuse resolver to preserve stat cache across re-registrations.
if (!this._resolver) {
Expand Down Expand Up @@ -135,6 +145,21 @@ export class TerminalLinkManager {
onLinkLeave,
);
this._disposables.push(this._terminal.registerLinkProvider(urlProvider));

// xterm always registers its own OSC 8 hyperlink provider first. Without
// this, OSC 8 links use xterm's default confirm() + window.open() path,
// which is blocked in Electron and also bypasses our link preferences.
this._oscLinkHandler = {
allowNonHttpProtocols: false,
activate: (event, uri) => {
onUrlClick(event, uri);
},
hover: onLinkHover
? (event) => onLinkHover(event, { kind: "url" })
: undefined,
leave: onLinkLeave ? () => onLinkLeave() : undefined,
};
this._terminal.options.linkHandler = this._oscLinkHandler;
}

// 3. SUPERSET ADDITION: Word link detector (lowest priority).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
"relative flex w-full items-center pl-3 pr-2 text-left text-sm",
onClick && "cursor-pointer hover:bg-muted/50",
"group",
"py-1.5",
"py-2",
isActive && "bg-muted",
className,
)}
Expand Down Expand Up @@ -211,86 +211,80 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
</TooltipContent>
</Tooltip>

<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] grid-rows-2 items-center gap-x-1.5 gap-y-0.5">
{isRenaming ? (
<RenameInput
value={renameValue}
onChange={onRenameValueChange}
onSubmit={onSubmitRename}
onCancel={onCancelRename}
className={cn(
"h-5 w-full -ml-1 border-none bg-transparent px-1 py-0 text-[13px] leading-tight outline-none",
)}
/>
) : (
<div className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1.5">
{isRenaming ? (
<RenameInput
value={renameValue}
onChange={onRenameValueChange}
onSubmit={onSubmitRename}
onCancel={onCancelRename}
className={cn(
"h-5 w-full -ml-1 border-none bg-transparent px-1 py-0 text-[13px] leading-tight outline-none",
)}
/>
) : (
<span
className={cn(
"truncate text-[13px] leading-tight transition-colors",
isActive ? "text-foreground" : "text-foreground/80",
)}
>
{name || branch}
</span>
)}

<div className="col-start-2 row-start-1 grid h-5 shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1">
{creationStatusText ? (
<span
className={cn(
"truncate text-[13px] leading-tight transition-colors",
isActive ? "text-foreground" : "text-foreground/80",
"text-[11px]",
creationStatus === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{name || branch}
{creationStatusText}
</span>
)}

<div className="col-start-2 row-start-1 grid h-5 shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1">
{creationStatusText ? (
<span
className={cn(
"text-[11px]",
creationStatus === "failed"
? "text-destructive"
: "text-muted-foreground",
) : (
<>
{diffStats &&
(diffStats.additions > 0 || diffStats.deletions > 0) && (
<DashboardSidebarWorkspaceDiffStats
additions={diffStats.additions}
deletions={diffStats.deletions}
isActive={isActive}
/>
)}
>
{creationStatusText}
</span>
) : (
<>
{diffStats &&
(diffStats.additions > 0 || diffStats.deletions > 0) && (
<DashboardSidebarWorkspaceDiffStats
additions={diffStats.additions}
deletions={diffStats.deletions}
isActive={isActive}
<div className="invisible flex items-center justify-end gap-1.5 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100">
{shortcutLabel && (
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{shortcutLabel}
</span>
)}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDeleteClick();
}}
className="flex items-center justify-center text-muted-foreground hover:text-foreground"
aria-label="Close workspace"
>
<HiMiniXMark className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<HotkeyLabel
label="Close workspace"
id={isActive ? "CLOSE_WORKSPACE" : undefined}
/>
)}
<div className="invisible flex items-center justify-end gap-1.5 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100">
{shortcutLabel && (
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{shortcutLabel}
</span>
)}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDeleteClick();
}}
className="flex items-center justify-center text-muted-foreground hover:text-foreground"
aria-label="Close workspace"
>
<HiMiniXMark className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<HotkeyLabel
label="Close workspace"
id={isActive ? "CLOSE_WORKSPACE" : undefined}
/>
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>

<span className="col-start-1 row-start-2 truncate font-mono text-[11px] leading-tight text-muted-foreground/60">
{branch}
</span>
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
</div>
</div>
Expand Down
Loading
Loading