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
30 changes: 29 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens Zed's ssh deep link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project");

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal("zed://extension/attacker"),
electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open remote editor URLs with userinfo", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand All @@ -100,9 +127,10 @@ describe("ElectronShell", () => {
electronShell.openExternal(
"vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false]);
assert.deepEqual(results, [false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
16 changes: 11 additions & 5 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
};

// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+鈥)
// must reach the OS handler; every other non-web scheme stays blocked.
// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+鈥,
// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
// scheme stays blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_EDITOR_PROTOCOLS = new Set(
REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
Expand All @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);

// Zed's host sits in the first path segment, so it needs its own userinfo ban.
const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
url.username.length === 0 &&
url.password.length === 0 &&
url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length;
(url.protocol === "zed:"
? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import { TestClock } from "effect/testing";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import { PersistenceSqlError } from "../../persistence/Errors.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
Expand Down Expand Up @@ -1039,6 +1039,8 @@ describe("OrchestrationEngine", () => {
},
}),
});
// Same-tick links must replace the old PR, not rely on timestamp ordering.
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse(now()));
try {
const projectId = ProjectId.make("pr-race-project");
const threadId = ThreadId.make("pr-race-thread");
Expand Down Expand Up @@ -1141,6 +1143,9 @@ describe("OrchestrationEngine", () => {
if (change === "delete") return;
const current = (await system.readModel()).threads[0];
expect(current?.branchPullRequest ?? null).toBeNull();
expect(current?.pullRequests.map((link) => link.number)).toEqual(
change === "unlink" ? [] : change === "relink" ? [3] : [1],
);
expect(current?.linkedPullRequest ?? null).toEqual(
change === "unlink"
? null
Expand All @@ -1149,6 +1154,7 @@ describe("OrchestrationEngine", () => {
: previous,
);
} finally {
clock.mockRestore();
await system.dispose();
}
},
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,29 @@ const makeOrchestrationEngine = Effect.gen(function* () {
});
}

// New and moved projects do not carry a resolved identity in the event-derived
// command model. Legacy PR edits need it to identify the link they replace.
if (
envelope.command.type === "thread.meta.update" &&
envelope.command.linkedPullRequest !== undefined
) {
const threadId = envelope.command.threadId;
const thread = commandReadModel.threads.find((thread) => thread.id === threadId);
if (thread !== undefined) {
const project = yield* projectionSnapshotQuery.getProjectShellById(thread.projectId);
if (Option.isSome(project)) {
commandReadModel = {
...commandReadModel,
projects: commandReadModel.projects.map((entry) =>
entry.id === thread.projectId
? { ...entry, repositoryIdentity: project.value.repositoryIdentity }
: entry,
),
};
}
}
}

// Command snapshots omit activities at startup and cap them while running.
// Read this request's durable state before deciding how to send the answer.
const userInputActivity =
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1878,9 +1878,9 @@ export default function GitActionsControl({
<span className="text-muted-foreground">Excluded</span>
) : (
<>
<span className="text-success">+{file.insertions}</span>
<span className="text-diff-addition">+{file.insertions}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-destructive">-{file.deletions}</span>
<span className="text-diff-deletion">-{file.deletions}</span>
</>
)}
</span>
Expand All @@ -1891,11 +1891,11 @@ export default function GitActionsControl({
</div>
</ScrollArea>
<div className="flex justify-end font-mono">
<span className="text-success">
<span className="text-diff-addition">
+{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)}
</span>
<span className="text-muted-foreground"> / </span>
<span className="text-destructive">
<span className="text-diff-deletion">
-{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
</span>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1932,8 +1932,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : null}
{diff ? (
<span className="shrink-0 font-mono">
<span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
<span className="text-red-600 dark:text-red-400">鈭抺diff.deletions}</span>
<span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
<span className="text-diff-deletion-foreground">鈭抺diff.deletions}</span>
</span>
) : null}
<span
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/DiffStatLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
className,
)}
>
<span aria-hidden="true" className="font-mono text-success">
<span aria-hidden="true" className="font-mono text-diff-addition">
+{formatCompactDiffCount(additions)}
</span>
<span aria-hidden="true" className="font-mono text-destructive">
<span aria-hidden="true" className="font-mono text-diff-deletion">
-{formatCompactDiffCount(deletions)}
</span>
</span>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1582,7 +1582,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
markdownCwd={ctx.markdownCwd}
/>
</div>
<div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
<div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100">
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
Expand Down Expand Up @@ -1734,7 +1734,7 @@ function AssistantMessageMeta({
"flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200",
alwaysVisible
? "opacity-100"
: "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100",
: "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100",
className,
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,8 @@ export function PullRequestDiffStat({
}
return (
<span className={cn("inline-flex items-baseline gap-1 tabular-nums", className)}>
<span className="text-emerald-600 dark:text-emerald-300/90">
+{additions.toLocaleString()}
</span>
<span className="text-destructive">-{deletions.toLocaleString()}</span>
<span className="text-diff-addition-foreground">+{additions.toLocaleString()}</span>
<span className="text-diff-deletion">-{deletions.toLocaleString()}</span>
</span>
);
}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Contrast"]
: []),
...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
? ["Diff colors"]
: []),
...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
? ["Panel animations"]
: []),
Expand Down Expand Up @@ -597,6 +600,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.browserLinkTarget,
settings.browserAutoShowFloatingPreview,
settings.appearanceContrast,
settings.diffColorScheme,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
settings.confirmThreadArchive,
Expand Down Expand Up @@ -701,6 +705,7 @@ export function useSettingsRestore(onRestored?: () => void) {
}
updateSettings({
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
Expand Down Expand Up @@ -1240,6 +1245,52 @@ export function AppearanceSettingsPanel() {
}
/>
) : null}
<SettingsRow
{...searchableSetting("diff-color-scheme")}
description="Choose colors for additions and deletions, including change counts."
resetAction={
settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme ? (
<SettingResetButton
label="diff colors"
onClick={() =>
updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme })
}
/>
) : null
}
control={
<div className="w-full sm:w-40">
<Select
value={settings.diffColorScheme}
onValueChange={(value) => {
if (value === "red-green" || value === "blue-orange")
updateSettings({ diffColorScheme: value });
}}
>
<SelectTrigger size="sm" className="w-full min-w-0" aria-label="Diff colors">
<span
aria-hidden="true"
className={
settings.diffColorScheme === "blue-orange"
? "flex shrink-0 flex-row-reverse gap-1"
: "flex shrink-0 gap-1"
}
>
<span className="size-2 rounded-full bg-[var(--diff-deletion)]" />
<span className="size-2 rounded-full bg-[var(--diff-addition)]" />
</span>
<SelectValue>
{settings.diffColorScheme === "blue-orange" ? "Blue & orange" : "Red & green"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="red-green">Red & green (default)</SelectItem>
<SelectItem value="blue-orange">Blue & orange</SelectItem>
</SelectPopup>
</Select>
</div>
}
/>
</SettingsSection>

<SettingsSection id="motion" title="Motion">
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/appearance",
searchTerms: ["transparent transparency solid menus dialogs composer"],
},
{
id: "diff-color-scheme",
title: "Diff colors",
to: "/settings/appearance",
searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"],
},
{
id: "panel-animations",
title: "Panel animations",
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--color-update-surface: var(--update-surface);
--color-success-foreground: var(--success-foreground);
--color-success: var(--success);
--color-diff-addition: var(--diff-addition);
--color-diff-deletion: var(--diff-deletion);
--color-diff-addition-foreground: var(--diff-addition-foreground);
--color-diff-deletion-foreground: var(--diff-deletion-foreground);
--color-info-foreground: var(--info-foreground);
--color-info: var(--info);
--color-destructive-foreground: var(--destructive-foreground);
Expand Down Expand Up @@ -1955,6 +1959,28 @@ code {
}
}

:root {
--diff-addition: var(--success);
--diff-deletion: var(--destructive);
--diff-addition-foreground: light-dark(var(--color-emerald-600), var(--color-emerald-400));
--diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400));
}

:root[data-diff-color-scheme="blue-orange"] {
--diff-addition: light-dark(var(--color-blue-600), var(--color-blue-400));
--diff-deletion: light-dark(var(--color-orange-600), var(--color-orange-400));
--diff-addition-foreground: var(--diff-addition);
--diff-deletion-foreground: var(--diff-deletion);
--diffs-addition-color-override: var(--diff-addition);
--diffs-deletion-color-override: var(--diff-deletion);
--trees-status-added-override: var(--diff-addition);
--trees-status-deleted-override: var(--diff-deletion);
--trees-status-untracked-override: var(--diff-addition);
--trees-git-added-color-override: var(--diff-addition);
--trees-git-deleted-color-override: var(--diff-deletion);
--trees-git-untracked-color-override: var(--diff-addition);
}

.diff-render-surface diffs-container {
border: 0;
border-radius: 0;
Expand Down
Loading
Loading