Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -10,7 +10,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
isConnected: false,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
isConnected: true,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
attentionReason: "finished",
|
||||
trigger: "focus-entry",
|
||||
hasDeferredFocusEntryClear: true,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
attentionReason: "finished",
|
||||
trigger: "input-focus",
|
||||
hasDeferredFocusEntryClear: true,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
@@ -91,7 +91,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
attentionReason: "finished",
|
||||
trigger: "prompt-send",
|
||||
hasDeferredFocusEntryClear: true,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
@@ -102,7 +102,7 @@ describe("shouldClearAgentAttention", () => {
|
||||
attentionReason: "finished",
|
||||
trigger: "agent-blur",
|
||||
hasDeferredFocusEntryClear: true,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,7 @@ export type AgentAttentionClearTrigger =
|
||||
| "prompt-send"
|
||||
| "agent-blur";
|
||||
|
||||
export function shouldClearAgentAttention(
|
||||
input: ShouldClearAgentAttentionInput
|
||||
): boolean {
|
||||
export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean {
|
||||
const agentId = input.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return false;
|
||||
@@ -29,10 +27,7 @@ export function shouldClearAgentAttention(
|
||||
if (input.attentionReason === "permission") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
input.trigger === "focus-entry" &&
|
||||
input.hasDeferredFocusEntryClear === true
|
||||
) {
|
||||
if (input.trigger === "focus-entry" && input.hasDeferredFocusEntryClear === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
|
||||
type PendingPermissionEntry = {
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
* Derives the branch label to display for an agent.
|
||||
* Returns null if there's no branch to show (not a git repo, or on the base branch).
|
||||
*/
|
||||
export function deriveBranchLabel(
|
||||
checkout: CheckoutStatusPayload | null
|
||||
): string | null {
|
||||
export function deriveBranchLabel(checkout: CheckoutStatusPayload | null): string | null {
|
||||
if (!checkout || !checkout.isGit) {
|
||||
return null;
|
||||
}
|
||||
@@ -29,10 +27,7 @@ export function deriveBranchLabel(
|
||||
* If inside a Paseo worktree, shows just the worktree-relative path.
|
||||
* Otherwise uses the repo root or cwd.
|
||||
*/
|
||||
export function deriveProjectPath(
|
||||
cwd: string,
|
||||
checkout: CheckoutStatusPayload | null
|
||||
): string {
|
||||
export function deriveProjectPath(cwd: string, checkout: CheckoutStatusPayload | null): string {
|
||||
const basePath = checkout?.isGit ? (checkout.repoRoot ?? cwd) : cwd;
|
||||
const worktreeMarker = ".paseo/worktrees/";
|
||||
const idx = basePath.indexOf(worktreeMarker);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveProjectDisplayName,
|
||||
deriveRemoteProjectKey,
|
||||
groupAgents,
|
||||
} from "./agent-grouping";
|
||||
import { deriveProjectDisplayName, deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
|
||||
@@ -33,9 +29,7 @@ describe("deriveRemoteProjectKey", () => {
|
||||
|
||||
it("includes host for non-GitHub remotes", () => {
|
||||
const gitlab = "git@gitlab.example.com:group/repo.git";
|
||||
expect(deriveRemoteProjectKey(gitlab)).toBe(
|
||||
"remote:gitlab.example.com/group/repo"
|
||||
);
|
||||
expect(deriveRemoteProjectKey(gitlab)).toBe("remote:gitlab.example.com/group/repo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +39,7 @@ describe("deriveProjectDisplayName", () => {
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "remote:github.com/getpaseo/paseo",
|
||||
projectName: "paseo",
|
||||
})
|
||||
}),
|
||||
).toBe("getpaseo/paseo");
|
||||
});
|
||||
|
||||
@@ -54,7 +48,7 @@ describe("deriveProjectDisplayName", () => {
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "remote:gitlab.example.com/group/repo",
|
||||
projectName: "repo",
|
||||
})
|
||||
}),
|
||||
).toBe("group/repo");
|
||||
});
|
||||
|
||||
@@ -63,7 +57,7 @@ describe("deriveProjectDisplayName", () => {
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "/Users/me/dev/paseo",
|
||||
projectName: "paseo",
|
||||
})
|
||||
}),
|
||||
).toBe("paseo");
|
||||
});
|
||||
});
|
||||
@@ -80,10 +74,7 @@ describe("groupAgents", () => {
|
||||
});
|
||||
|
||||
expect(activeGroups).toHaveLength(1);
|
||||
expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
]);
|
||||
expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual(["a1", "a2"]);
|
||||
});
|
||||
|
||||
it("falls back to cwd grouping when remote URL is unavailable", () => {
|
||||
|
||||
@@ -87,9 +87,7 @@ export function deriveRemoteProjectKey(remoteUrl: string | null): string | null
|
||||
* https://github.com/anthropics/claude-code.git -> anthropics/claude-code
|
||||
* https://github.com/anthropics/claude-code -> anthropics/claude-code
|
||||
*/
|
||||
export function parseRepoNameFromRemoteUrl(
|
||||
remoteUrl: string | null
|
||||
): string | null {
|
||||
export function parseRepoNameFromRemoteUrl(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
@@ -133,9 +131,7 @@ export function parseRepoNameFromRemoteUrl(
|
||||
* Examples:
|
||||
* git@github.com:anthropics/claude-code.git -> claude-code
|
||||
*/
|
||||
export function parseRepoShortNameFromRemoteUrl(
|
||||
remoteUrl: string | null
|
||||
): string | null {
|
||||
export function parseRepoShortNameFromRemoteUrl(remoteUrl: string | null): string | null {
|
||||
const fullName = parseRepoNameFromRemoteUrl(remoteUrl);
|
||||
if (!fullName) {
|
||||
return null;
|
||||
@@ -204,7 +200,7 @@ export function deriveDateGroup(lastActivityAt: Date): string {
|
||||
const activityDate = new Date(
|
||||
lastActivityAt.getFullYear(),
|
||||
lastActivityAt.getMonth(),
|
||||
lastActivityAt.getDate()
|
||||
lastActivityAt.getDate(),
|
||||
);
|
||||
|
||||
if (activityDate.getTime() >= today.getTime()) {
|
||||
@@ -268,7 +264,7 @@ const MAX_INACTIVE_PER_PROJECT = 5;
|
||||
*/
|
||||
export function groupAgents(
|
||||
agents: AggregatedAgent[],
|
||||
options?: GroupAgentsOptions
|
||||
options?: GroupAgentsOptions,
|
||||
): GroupedAgents {
|
||||
const activeAgents: AggregatedAgent[] = [];
|
||||
const inactiveAgents: AggregatedAgent[] = [];
|
||||
@@ -302,9 +298,7 @@ export function groupAgents(
|
||||
{ trulyActive: AggregatedAgent[]; recentlyActive: AggregatedAgent[] }
|
||||
>();
|
||||
for (const agent of activeAgents) {
|
||||
const remoteKey = deriveRemoteProjectKey(
|
||||
options?.getRemoteUrl?.(agent) ?? null
|
||||
);
|
||||
const remoteKey = deriveRemoteProjectKey(options?.getRemoteUrl?.(agent) ?? null);
|
||||
const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
|
||||
const existing = projectMap.get(projectKey) || {
|
||||
trulyActive: [],
|
||||
@@ -328,24 +322,15 @@ export function groupAgents(
|
||||
const activeGroups: ProjectGroup[] = [];
|
||||
for (const [projectKey, { trulyActive, recentlyActive }] of projectMap) {
|
||||
// Sort both arrays by lastActivityAt (newest first)
|
||||
trulyActive.sort(
|
||||
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
);
|
||||
recentlyActive.sort(
|
||||
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
);
|
||||
trulyActive.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
recentlyActive.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
|
||||
// All truly active agents shown, limit recently active to MAX_INACTIVE_PER_PROJECT
|
||||
const limitedRecentlyActive = recentlyActive.slice(
|
||||
0,
|
||||
MAX_INACTIVE_PER_PROJECT
|
||||
);
|
||||
const limitedRecentlyActive = recentlyActive.slice(0, MAX_INACTIVE_PER_PROJECT);
|
||||
const combinedAgents = [...trulyActive, ...limitedRecentlyActive];
|
||||
|
||||
// Re-sort combined list by lastActivityAt
|
||||
combinedAgents.sort(
|
||||
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
);
|
||||
combinedAgents.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
|
||||
activeGroups.push({
|
||||
projectKey,
|
||||
@@ -378,9 +363,7 @@ export function groupAgents(
|
||||
for (const label of dateOrder) {
|
||||
const dateAgents = dateMap.get(label);
|
||||
if (dateAgents && dateAgents.length > 0) {
|
||||
dateAgents.sort(
|
||||
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
);
|
||||
dateAgents.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
inactiveGroups.push({ label, agents: dateAgents });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ export function getInitDeferred(key: string): DeferredInit | undefined {
|
||||
return initPromises.get(key);
|
||||
}
|
||||
|
||||
export function createInitDeferred(
|
||||
key: string,
|
||||
requestDirection: "tail" | "after"
|
||||
): DeferredInit {
|
||||
export function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
|
||||
|
||||
export function derivePendingPermissionKey(
|
||||
agentId: string,
|
||||
request: AgentPermissionRequest
|
||||
request: AgentPermissionRequest,
|
||||
): string {
|
||||
const fallbackId =
|
||||
request.id ||
|
||||
@@ -19,7 +19,9 @@ export function derivePendingPermissionKey(
|
||||
export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
|
||||
const createdAt = new Date(snapshot.createdAt);
|
||||
const updatedAt = new Date(snapshot.updatedAt);
|
||||
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
|
||||
const lastUserMessageAt = snapshot.lastUserMessageAt
|
||||
? new Date(snapshot.lastUserMessageAt)
|
||||
: null;
|
||||
const attentionTimestamp = snapshot.attentionTimestamp
|
||||
? new Date(snapshot.attentionTimestamp)
|
||||
: null;
|
||||
@@ -53,4 +55,3 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
labels: snapshot.labels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ describe("collectAgentWorkingDirectorySuggestions", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/project-alpha",
|
||||
"/Users/me/project-beta",
|
||||
]);
|
||||
expect(results).toEqual(["/Users/me/project-alpha", "/Users/me/project-beta"]);
|
||||
});
|
||||
|
||||
it("excludes Paseo-owned worktree paths", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AgentWorkingDirectorySource {
|
||||
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
|
||||
|
||||
export function collectAgentWorkingDirectorySuggestions(
|
||||
sources: Iterable<AgentWorkingDirectorySource>
|
||||
sources: Iterable<AgentWorkingDirectorySource>,
|
||||
): string[] {
|
||||
const lastSeenByPath = new Map<string, number>();
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
type OfflineAction =
|
||||
| "create"
|
||||
| "resume"
|
||||
| "dictation"
|
||||
| "import_list";
|
||||
type OfflineAction = "create" | "resume" | "dictation" | "import_list";
|
||||
|
||||
type AnalyticsEvent =
|
||||
| {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AppState, Platform } from "react-native";
|
||||
|
||||
export function getIsAppActivelyVisible(
|
||||
appState: string = AppState.currentState
|
||||
): boolean {
|
||||
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
|
||||
if (appState !== "active") {
|
||||
return false;
|
||||
}
|
||||
@@ -11,8 +9,7 @@ export function getIsAppActivelyVisible(
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const documentVisible = typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const windowFocused =
|
||||
typeof document === "undefined" ||
|
||||
typeof document.hasFocus !== "function" ||
|
||||
|
||||
@@ -33,4 +33,3 @@ export class AttemptGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ describe("client-id", () => {
|
||||
it("creates and persists a client id when missing", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12345678-1234-1234-1234-1234567890ab",
|
||||
);
|
||||
|
||||
const mod = await import("./client-id");
|
||||
const key = await mod.getOrCreateClientId();
|
||||
@@ -37,7 +39,7 @@ describe("client-id", () => {
|
||||
expect(key).toBe("cid_123456781234123412341234567890ab");
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:client-id-v1",
|
||||
"cid_123456781234123412341234567890ab"
|
||||
"cid_123456781234123412341234567890ab",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,4 +13,3 @@ export function takeCommandCenterFocusRestoreElement(): HTMLElement | null {
|
||||
export function clearCommandCenterFocusRestoreElement(): void {
|
||||
focusRestoreElement = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,9 +94,7 @@ describe("confirmDialog", () => {
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(blurMock).toHaveBeenCalledTimes(1);
|
||||
expect(browserConfirm).toHaveBeenCalledWith(
|
||||
"Restart host\n\nThis will restart the daemon."
|
||||
);
|
||||
expect(browserConfirm).toHaveBeenCalledWith("Restart host\n\nThis will restart the daemon.");
|
||||
});
|
||||
|
||||
it("throws on web when no confirm backend exists", async () => {
|
||||
@@ -106,22 +104,16 @@ describe("confirmDialog", () => {
|
||||
confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
|
||||
});
|
||||
|
||||
it("uses native Alert on iOS/Android", async () => {
|
||||
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
|
||||
alertMock.mockImplementation(
|
||||
(
|
||||
_title: string,
|
||||
_message: string,
|
||||
buttons?: AlertButton[]
|
||||
) => {
|
||||
const confirmButton = buttons?.[1];
|
||||
confirmButton?.onPress?.();
|
||||
}
|
||||
);
|
||||
alertMock.mockImplementation((_title: string, _message: string, buttons?: AlertButton[]) => {
|
||||
const confirmButton = buttons?.[1];
|
||||
confirmButton?.onPress?.();
|
||||
});
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
|
||||
@@ -43,7 +43,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
{
|
||||
cancelable: true,
|
||||
onDismiss: () => resolve(false),
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,17 +10,11 @@ function makeDirect(id: string, endpoint: string): HostConnection {
|
||||
return { id, type: "directTcp", endpoint };
|
||||
}
|
||||
|
||||
function makeRelay(
|
||||
id: string,
|
||||
relayEndpoint: string,
|
||||
daemonPublicKeyB64 = "abc"
|
||||
): HostConnection {
|
||||
function makeRelay(id: string, relayEndpoint: string, daemonPublicKeyB64 = "abc"): HostConnection {
|
||||
return { id, type: "relay", relayEndpoint, daemonPublicKeyB64 };
|
||||
}
|
||||
|
||||
function probes(
|
||||
input: Record<string, ConnectionProbeState>
|
||||
): Map<string, ConnectionProbeState> {
|
||||
function probes(input: Record<string, ConnectionProbeState>): Map<string, ConnectionProbeState> {
|
||||
return new Map(Object.entries(input));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ function getAvailableLatency(input: {
|
||||
return probe?.status === "available" ? probe.latencyMs : null;
|
||||
}
|
||||
|
||||
export function selectBestConnection(
|
||||
input: SelectBestConnectionInput
|
||||
): string | null {
|
||||
export function selectBestConnection(input: SelectBestConnectionInput): string | null {
|
||||
const { candidates, probeByConnectionId } = input;
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -30,9 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
}): string {
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
|
||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||
}
|
||||
|
||||
@@ -20,9 +20,7 @@ export function formatConnectionStatus(status: HostRuntimeConnectionStatus): str
|
||||
|
||||
export type ConnectionStatusTone = "success" | "warning" | "error" | "muted";
|
||||
|
||||
export function getConnectionStatusTone(
|
||||
status: HostRuntimeConnectionStatus
|
||||
): ConnectionStatusTone {
|
||||
export function getConnectionStatusTone(status: HostRuntimeConnectionStatus): ConnectionStatusTone {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "success";
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
describe('readFiniteScreenPoint', () => {
|
||||
it('returns finite screen coordinates', () => {
|
||||
describe("readFiniteScreenPoint", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
|
||||
screenX: 1280,
|
||||
screenY: 720,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects NaN screen coordinates', () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull()
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull()
|
||||
})
|
||||
it("rejects NaN screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects infinite screen coordinates', () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull()
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull()
|
||||
})
|
||||
it("rejects infinite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects missing screen coordinates', () => {
|
||||
expect(readFiniteScreenPoint(undefined)).toBeNull()
|
||||
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull()
|
||||
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull()
|
||||
})
|
||||
})
|
||||
it("rejects missing screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint(undefined)).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
export type DesktopWindowScreenPoint = {
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
type ScreenPointInput =
|
||||
| {
|
||||
screenX?: unknown
|
||||
screenY?: unknown
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isInteractiveDesktopDragTarget } from './desktop-window'
|
||||
import { isInteractiveDesktopDragTarget } from "./desktop-window";
|
||||
|
||||
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
|
||||
return {
|
||||
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
|
||||
} as unknown as EventTarget
|
||||
} as unknown as EventTarget;
|
||||
}
|
||||
|
||||
describe('isInteractiveDesktopDragTarget', () => {
|
||||
it('treats focusable pressables as interactive drag exemptions', () => {
|
||||
describe("isInteractiveDesktopDragTarget", () => {
|
||||
it("treats focusable pressables as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes('[tabindex]'),
|
||||
})
|
||||
matchesSelector: (selector) => selector.includes("[tabindex]"),
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
|
||||
})
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats semantic button targets as interactive drag exemptions', () => {
|
||||
it("treats semantic button targets as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes("[role='button']"),
|
||||
})
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
|
||||
})
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-interactive targets', () => {
|
||||
it("returns false for non-interactive targets", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: () => false,
|
||||
})
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(false)
|
||||
})
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the target does not support closest()', () => {
|
||||
expect(isInteractiveDesktopDragTarget(null)).toBe(false)
|
||||
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false)
|
||||
})
|
||||
})
|
||||
it("returns false when the target does not support closest()", () => {
|
||||
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
|
||||
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from 'react-native'
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import {
|
||||
getIsDesktopMac,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
} from '@/constants/layout'
|
||||
import { getDesktopWindow } from '@/desktop/electron/window'
|
||||
import { isDesktop } from '@/desktop/host'
|
||||
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = getDesktopWindow()
|
||||
if (win && typeof win.toggleMaximize === 'function') {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.toggleMaximize === "function") {
|
||||
try {
|
||||
await win.toggleMaximize()
|
||||
await win.toggleMaximize();
|
||||
} catch (error) {
|
||||
console.warn('[DesktopWindow] toggleMaximize failed', error)
|
||||
console.warn("[DesktopWindow] toggleMaximize failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,159 +28,159 @@ export async function toggleMaximize() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'button, a, input, textarea, select, ' +
|
||||
"button, a, input, textarea, select, " +
|
||||
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
|
||||
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
|
||||
"[role='menuitem'], [tabindex], [contenteditable='true']"
|
||||
"[role='menuitem'], [tabindex], [contenteditable='true']";
|
||||
|
||||
const DOUBLE_CLICK_MS = 300
|
||||
const DOUBLE_CLICK_MS = 300;
|
||||
|
||||
type DesktopDragViewProps = Pick<
|
||||
ViewProps,
|
||||
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'
|
||||
>
|
||||
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
||||
>;
|
||||
|
||||
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null
|
||||
if (!candidate || typeof candidate.closest !== 'function') {
|
||||
return false
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
||||
if (!candidate || typeof candidate.closest !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(candidate.closest(INTERACTIVE_SELECTOR))
|
||||
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
const isDragging = useRef(false)
|
||||
const lastPointerDownAt = useRef(0)
|
||||
const isActive = Platform.OS === 'web' && isDesktop()
|
||||
const isDragging = useRef(false);
|
||||
const lastPointerDownAt = useRef(0);
|
||||
const isActive = Platform.OS === "web" && isDesktop();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
if (!isActive) return;
|
||||
function handleBlur() {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
getDesktopWindow()?.endMove?.()
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
window.addEventListener('blur', handleBlur)
|
||||
return () => window.removeEventListener('blur', handleBlur)
|
||||
}, [isActive])
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => window.removeEventListener("blur", handleBlur);
|
||||
}, [isActive]);
|
||||
|
||||
return useMemo((): DesktopDragViewProps => {
|
||||
if (!isActive) return {}
|
||||
if (!isActive) return {};
|
||||
|
||||
function stopDrag(e: RNPointerEvent) {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
|
||||
const el = e.currentTarget as unknown as Element | null
|
||||
if (el && 'releasePointerCapture' in el) {
|
||||
el.releasePointerCapture(e.nativeEvent.pointerId)
|
||||
const el = e.currentTarget as unknown as Element | null;
|
||||
if (el && "releasePointerCapture" in el) {
|
||||
el.releasePointerCapture(e.nativeEvent.pointerId);
|
||||
}
|
||||
getDesktopWindow()?.endMove?.()
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: (e: RNPointerEvent) => {
|
||||
if (e.nativeEvent.button !== 0) return
|
||||
if (e.nativeEvent.button !== 0) return;
|
||||
|
||||
if (isInteractiveDesktopDragTarget(e.target)) return
|
||||
if (isInteractiveDesktopDragTarget(e.target)) return;
|
||||
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
|
||||
lastPointerDownAt.current = 0
|
||||
void toggleMaximize()
|
||||
return
|
||||
lastPointerDownAt.current = 0;
|
||||
void toggleMaximize();
|
||||
return;
|
||||
}
|
||||
lastPointerDownAt.current = now
|
||||
lastPointerDownAt.current = now;
|
||||
|
||||
const win = getDesktopWindow()
|
||||
if (!win?.startMove) return
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
|
||||
if (!screenPoint) return
|
||||
const win = getDesktopWindow();
|
||||
if (!win?.startMove) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) return;
|
||||
|
||||
isDragging.current = true
|
||||
const el = e.currentTarget as unknown as Element
|
||||
el.setPointerCapture(e.nativeEvent.pointerId)
|
||||
win.startMove(screenPoint.screenX, screenPoint.screenY)
|
||||
isDragging.current = true;
|
||||
const el = e.currentTarget as unknown as Element;
|
||||
el.setPointerCapture(e.nativeEvent.pointerId);
|
||||
win.startMove(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerMove: (e: RNPointerEvent) => {
|
||||
if (!isDragging.current) return
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
|
||||
if (!isDragging.current) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) {
|
||||
stopDrag(e)
|
||||
return
|
||||
stopDrag(e);
|
||||
return;
|
||||
}
|
||||
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY)
|
||||
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerUp: stopDrag,
|
||||
onPointerCancel: stopDrag,
|
||||
}
|
||||
}, [isActive])
|
||||
};
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web' || !getIsDesktopMac()) return
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
|
||||
let disposed = false
|
||||
let cleanup: (() => void) | undefined
|
||||
let didCleanup = false
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return
|
||||
didCleanup = true
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn('[DesktopWindow] Failed to remove resize listener', error)
|
||||
})
|
||||
console.warn("[DesktopWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[DesktopWindow] Failed to remove resize listener', error)
|
||||
console.warn("[DesktopWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = getDesktopWindow()
|
||||
if (!win) return
|
||||
const win = getDesktopWindow();
|
||||
if (!win) return;
|
||||
|
||||
const fullscreen = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
|
||||
if (disposed) return
|
||||
setIsFullscreen(fullscreen)
|
||||
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
if (typeof win.onResized !== 'function') {
|
||||
return
|
||||
if (typeof win.onResized !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return
|
||||
const fs = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
|
||||
if (disposed) return
|
||||
setIsFullscreen(fs)
|
||||
})
|
||||
if (disposed) return;
|
||||
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup()
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
void setup()
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
runCleanup()
|
||||
}
|
||||
}, [])
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsDesktopMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 }
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,17 +306,13 @@ describe("highlightDiffFile", () => {
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
// Removed line: "const bar = 2;"
|
||||
const removedLine = hunk.lines.find(
|
||||
(l) => l.type === "remove" && l.content.includes("bar")
|
||||
);
|
||||
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
||||
expect(removedLine?.tokens).toBeDefined();
|
||||
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
|
||||
expect(removedNumber?.style).toBe("number");
|
||||
|
||||
// Added line: "const bar = 3;"
|
||||
const addedLine = hunk.lines.find(
|
||||
(l) => l.type === "add" && l.content.includes("bar")
|
||||
);
|
||||
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
|
||||
expect(addedNumber?.style).toBe("number");
|
||||
@@ -361,12 +357,10 @@ describe("highlightDiffFile", () => {
|
||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to C code", () => {
|
||||
@@ -379,12 +373,10 @@ describe("highlightDiffFile", () => {
|
||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Java code", () => {
|
||||
@@ -394,17 +386,13 @@ describe("highlightDiffFile", () => {
|
||||
|
||||
const classLine = hunk.lines[1];
|
||||
expect(classLine.tokens).toBeDefined();
|
||||
expect(
|
||||
classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")
|
||||
).toBe(true);
|
||||
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||
@@ -413,45 +401,39 @@ describe("highlightDiffFile", () => {
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Go code", () => {
|
||||
const files = parseDiff(GO_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to PHP code", () => {
|
||||
const files = parseDiff(PHP_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("$version")
|
||||
(line) => line.type === "add" && line.content.includes("$version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to YAML code", () => {
|
||||
const files = parseDiff(YAML_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("count")
|
||||
(line) => line.type === "add" && line.content.includes("count"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
@@ -462,7 +444,7 @@ describe("highlightDiffFile", () => {
|
||||
const files = parseDiff(XML_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("<count>")
|
||||
(line) => line.type === "add" && line.content.includes("<count>"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { highlightCode, isLanguageSupported, type HighlightToken } from '@getpaseo/highlight'
|
||||
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'add' | 'remove' | 'context' | 'header'
|
||||
content: string
|
||||
lineNumber?: number // Line number in the original/new file
|
||||
tokens?: HighlightToken[]
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
lineNumber?: number; // Line number in the original/new file
|
||||
tokens?: HighlightToken[];
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number
|
||||
oldCount: number
|
||||
newStart: number
|
||||
newCount: number
|
||||
lines: DiffLine[]
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface ParsedDiffFile {
|
||||
path: string
|
||||
isNew: boolean
|
||||
isDeleted: boolean
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: DiffHunk[]
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,87 +29,87 @@ export interface ParsedDiffFile {
|
||||
*/
|
||||
export function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||
if (!diffText || diffText.trim().length === 0) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: ParsedDiffFile[] = []
|
||||
const fileSections = diffText.split(/^diff --git /m).filter(Boolean)
|
||||
const files: ParsedDiffFile[] = [];
|
||||
const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const section of fileSections) {
|
||||
const lines = section.split('\n')
|
||||
const firstLine = lines[0]
|
||||
const lines = section.split("\n");
|
||||
const firstLine = lines[0];
|
||||
|
||||
// Detect new/deleted file
|
||||
const isNew = section.includes('new file mode') || section.includes('--- /dev/null')
|
||||
const isDeleted = section.includes('deleted file mode') || section.includes('+++ /dev/null')
|
||||
const isNew = section.includes("new file mode") || section.includes("--- /dev/null");
|
||||
const isDeleted = section.includes("deleted file mode") || section.includes("+++ /dev/null");
|
||||
|
||||
// Extract path
|
||||
let path = 'unknown'
|
||||
const pathMatch = firstLine.match(/a\/(.*?) b\//)
|
||||
let path = "unknown";
|
||||
const pathMatch = firstLine.match(/a\/(.*?) b\//);
|
||||
if (pathMatch) {
|
||||
path = pathMatch[1]
|
||||
path = pathMatch[1];
|
||||
} else {
|
||||
const newFileMatch = firstLine.match(/b\/(.+)$/)
|
||||
const newFileMatch = firstLine.match(/b\/(.+)$/);
|
||||
if (newFileMatch) {
|
||||
path = newFileMatch[1]
|
||||
path = newFileMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const hunks: DiffHunk[] = []
|
||||
let currentHunk: DiffHunk | null = null
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
const hunks: DiffHunk[] = [];
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const line = lines[i];
|
||||
|
||||
// Skip metadata lines
|
||||
if (line.startsWith('index ')) continue
|
||||
if (line.startsWith('--- ')) continue
|
||||
if (line.startsWith('+++ ')) continue
|
||||
if (line.startsWith('new file mode')) continue
|
||||
if (line.startsWith('deleted file mode')) continue
|
||||
if (line.startsWith("index ")) continue;
|
||||
if (line.startsWith("--- ")) continue;
|
||||
if (line.startsWith("+++ ")) continue;
|
||||
if (line.startsWith("new file mode")) continue;
|
||||
if (line.startsWith("deleted file mode")) continue;
|
||||
|
||||
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk)
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = {
|
||||
oldStart: parseInt(hunkMatch[1], 10),
|
||||
oldCount: parseInt(hunkMatch[2] ?? '1', 10),
|
||||
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
|
||||
newStart: parseInt(hunkMatch[3], 10),
|
||||
newCount: parseInt(hunkMatch[4] ?? '1', 10),
|
||||
lines: [{ type: 'header', content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
|
||||
}
|
||||
continue
|
||||
newCount: parseInt(hunkMatch[4] ?? "1", 10),
|
||||
lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) continue
|
||||
if (!currentHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({ type: 'add', content: line.slice(1) })
|
||||
additions++
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({ type: 'remove', content: line.slice(1) })
|
||||
deletions++
|
||||
} else if (line.startsWith(' ')) {
|
||||
currentHunk.lines.push({ type: 'context', content: line.slice(1) })
|
||||
} else if (line.length > 0 && !line.startsWith('\\')) {
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({ type: "add", content: line.slice(1) });
|
||||
additions++;
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({ type: "remove", content: line.slice(1) });
|
||||
deletions++;
|
||||
} else if (line.startsWith(" ")) {
|
||||
currentHunk.lines.push({ type: "context", content: line.slice(1) });
|
||||
} else if (line.length > 0 && !line.startsWith("\\")) {
|
||||
// Non-empty line that's not a "\ No newline" marker
|
||||
currentHunk.lines.push({ type: 'context', content: line })
|
||||
currentHunk.lines.push({ type: "context", content: line });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk)
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
files.push({ path, isNew, isDeleted, additions, deletions, hunks })
|
||||
files.push({ path, isNew, isDeleted, additions, deletions, hunks });
|
||||
}
|
||||
|
||||
return files
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,23 +117,23 @@ export function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||
* Returns a map of new line numbers to their content.
|
||||
*/
|
||||
export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
const lines = new Map<number, string>()
|
||||
const lines = new Map<number, string>();
|
||||
|
||||
for (const hunk of hunks) {
|
||||
let newLineNum = hunk.newStart
|
||||
let newLineNum = hunk.newStart;
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'header') continue
|
||||
if (line.type === "header") continue;
|
||||
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
lines.set(newLineNum, line.content)
|
||||
newLineNum++
|
||||
if (line.type === "add" || line.type === "context") {
|
||||
lines.set(newLineNum, line.content);
|
||||
newLineNum++;
|
||||
}
|
||||
// Remove lines don't appear in the new file
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,23 +141,23 @@ export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
* Returns a map of old line numbers to their content.
|
||||
*/
|
||||
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
const lines = new Map<number, string>()
|
||||
const lines = new Map<number, string>();
|
||||
|
||||
for (const hunk of hunks) {
|
||||
let oldLineNum = hunk.oldStart
|
||||
let oldLineNum = hunk.oldStart;
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'header') continue
|
||||
if (line.type === "header") continue;
|
||||
|
||||
if (line.type === 'remove' || line.type === 'context') {
|
||||
lines.set(oldLineNum, line.content)
|
||||
oldLineNum++
|
||||
if (line.type === "remove" || line.type === "context") {
|
||||
lines.set(oldLineNum, line.content);
|
||||
oldLineNum++;
|
||||
}
|
||||
// Add lines don't appear in the old file
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,100 +170,100 @@ export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
*/
|
||||
export function highlightDiffFile(file: ParsedDiffFile): ParsedDiffFile {
|
||||
if (!isLanguageSupported(file.path)) {
|
||||
return file
|
||||
return file;
|
||||
}
|
||||
|
||||
// Reconstruct both versions
|
||||
const newFileLines = reconstructNewFile(file.hunks)
|
||||
const oldFileLines = reconstructOldFile(file.hunks)
|
||||
const newFileLines = reconstructNewFile(file.hunks);
|
||||
const oldFileLines = reconstructOldFile(file.hunks);
|
||||
|
||||
// Build complete file content strings for highlighting
|
||||
const newFileContent = buildFileContent(newFileLines)
|
||||
const oldFileContent = buildFileContent(oldFileLines)
|
||||
const newFileContent = buildFileContent(newFileLines);
|
||||
const oldFileContent = buildFileContent(oldFileLines);
|
||||
|
||||
// Highlight both versions
|
||||
const newHighlighted = highlightCode(newFileContent, file.path)
|
||||
const oldHighlighted = highlightCode(oldFileContent, file.path)
|
||||
const newHighlighted = highlightCode(newFileContent, file.path);
|
||||
const oldHighlighted = highlightCode(oldFileContent, file.path);
|
||||
|
||||
// Build lookup maps: line number -> tokens
|
||||
const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted)
|
||||
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted)
|
||||
const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted);
|
||||
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted);
|
||||
|
||||
// Apply tokens to hunks
|
||||
const highlightedHunks = file.hunks.map((hunk) => {
|
||||
let oldLineNum = hunk.oldStart
|
||||
let newLineNum = hunk.newStart
|
||||
let oldLineNum = hunk.oldStart;
|
||||
let newLineNum = hunk.newStart;
|
||||
|
||||
const highlightedLines = hunk.lines.map((line): DiffLine => {
|
||||
if (line.type === 'header') {
|
||||
return line
|
||||
if (line.type === "header") {
|
||||
return line;
|
||||
}
|
||||
|
||||
let tokens: HighlightToken[] | undefined
|
||||
let tokens: HighlightToken[] | undefined;
|
||||
|
||||
if (line.type === 'add') {
|
||||
tokens = newTokensByLine.get(newLineNum)
|
||||
newLineNum++
|
||||
} else if (line.type === 'remove') {
|
||||
tokens = oldTokensByLine.get(oldLineNum)
|
||||
oldLineNum++
|
||||
} else if (line.type === 'context') {
|
||||
if (line.type === "add") {
|
||||
tokens = newTokensByLine.get(newLineNum);
|
||||
newLineNum++;
|
||||
} else if (line.type === "remove") {
|
||||
tokens = oldTokensByLine.get(oldLineNum);
|
||||
oldLineNum++;
|
||||
} else if (line.type === "context") {
|
||||
// Context lines exist in both - use new file version
|
||||
tokens = newTokensByLine.get(newLineNum)
|
||||
oldLineNum++
|
||||
newLineNum++
|
||||
tokens = newTokensByLine.get(newLineNum);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
return tokens ? { ...line, tokens } : line
|
||||
})
|
||||
return tokens ? { ...line, tokens } : line;
|
||||
});
|
||||
|
||||
return { ...hunk, lines: highlightedLines }
|
||||
})
|
||||
return { ...hunk, lines: highlightedLines };
|
||||
});
|
||||
|
||||
return { ...file, hunks: highlightedHunks }
|
||||
return { ...file, hunks: highlightedHunks };
|
||||
}
|
||||
|
||||
function buildFileContent(lineMap: Map<number, string>): string {
|
||||
if (lineMap.size === 0) return ''
|
||||
if (lineMap.size === 0) return "";
|
||||
|
||||
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b)
|
||||
const minLine = lineNumbers[0]
|
||||
const maxLine = lineNumbers[lineNumbers.length - 1]
|
||||
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
|
||||
const minLine = lineNumbers[0];
|
||||
const maxLine = lineNumbers[lineNumbers.length - 1];
|
||||
|
||||
const lines: string[] = []
|
||||
const lines: string[] = [];
|
||||
for (let i = minLine; i <= maxLine; i++) {
|
||||
lines.push(lineMap.get(i) ?? '')
|
||||
lines.push(lineMap.get(i) ?? "");
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildTokenLookup(
|
||||
lineMap: Map<number, string>,
|
||||
highlighted: HighlightToken[][]
|
||||
highlighted: HighlightToken[][],
|
||||
): Map<number, HighlightToken[]> {
|
||||
const lookup = new Map<number, HighlightToken[]>()
|
||||
const lookup = new Map<number, HighlightToken[]>();
|
||||
|
||||
if (lineMap.size === 0) return lookup
|
||||
if (lineMap.size === 0) return lookup;
|
||||
|
||||
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b)
|
||||
const minLine = lineNumbers[0]
|
||||
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
|
||||
const minLine = lineNumbers[0];
|
||||
|
||||
// highlighted array is 0-indexed, line numbers are 1-indexed
|
||||
for (let i = 0; i < highlighted.length; i++) {
|
||||
const lineNum = minLine + i
|
||||
const lineNum = minLine + i;
|
||||
if (lineMap.has(lineNum)) {
|
||||
lookup.set(lineNum, highlighted[i])
|
||||
lookup.set(lineNum, highlighted[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and highlight a complete diff
|
||||
*/
|
||||
export function parseAndHighlightDiff(diffText: string): ParsedDiffFile[] {
|
||||
const files = parseDiff(diffText)
|
||||
return files.map(highlightDiffFile)
|
||||
const files = parseDiff(diffText);
|
||||
return files.map(highlightDiffFile);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { encodeAttachmentsForSend } from "@/attachments/service";
|
||||
type ImageInput = AttachmentMetadata;
|
||||
|
||||
export async function encodeImages(
|
||||
images?: ImageInput[]
|
||||
images?: ImageInput[],
|
||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||
return await encodeAttachmentsForSend(images);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("buildAbsoluteExplorerPath", () => {
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
|
||||
})
|
||||
}),
|
||||
).toBe("/Users/moboudra/dev/paseo/packages/app/src/components/file-explorer-pane.tsx");
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("buildAbsoluteExplorerPath", () => {
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
entryPath: ".",
|
||||
})
|
||||
}),
|
||||
).toBe("/Users/moboudra/dev/paseo");
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("buildAbsoluteExplorerPath", () => {
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo/",
|
||||
entryPath: "README.md",
|
||||
})
|
||||
}),
|
||||
).toBe("/Users/moboudra/dev/paseo/README.md");
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("buildAbsoluteExplorerPath", () => {
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "C:\\repo\\paseo",
|
||||
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
|
||||
})
|
||||
}),
|
||||
).toBe("C:\\repo\\paseo\\packages\\app\\src\\components\\file-explorer-pane.tsx");
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("buildAbsoluteExplorerPath", () => {
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
entryPath: "/tmp/another/location.txt",
|
||||
})
|
||||
}),
|
||||
).toBe("/tmp/another/location.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,7 @@ interface BuildAbsoluteExplorerPathInput {
|
||||
|
||||
function isAbsolutePath(pathValue: string): boolean {
|
||||
return (
|
||||
pathValue.startsWith("/") ||
|
||||
pathValue.startsWith("\\\\") ||
|
||||
/^[A-Za-z]:[\\/]/.test(pathValue)
|
||||
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyFileMentionReplacement, findActiveFileMention } from "./file-mention-autocomplete";
|
||||
|
||||
describe('findActiveFileMention', () => {
|
||||
it('detects mentions at the start of input', () => {
|
||||
describe("findActiveFileMention", () => {
|
||||
it("detects mentions at the start of input", () => {
|
||||
const mention = findActiveFileMention({
|
||||
text: '@src/components',
|
||||
cursorIndex: '@src/components'.length,
|
||||
})
|
||||
text: "@src/components",
|
||||
cursorIndex: "@src/components".length,
|
||||
});
|
||||
expect(mention).toEqual({
|
||||
start: 0,
|
||||
end: '@src/components'.length,
|
||||
query: 'src/components',
|
||||
})
|
||||
})
|
||||
end: "@src/components".length,
|
||||
query: "src/components",
|
||||
});
|
||||
});
|
||||
|
||||
it('detects mentions in the middle of input using cursor position', () => {
|
||||
const text = 'read "@src/com" before merging'
|
||||
const cursorIndex = text.indexOf('"') + 9
|
||||
it("detects mentions in the middle of input using cursor position", () => {
|
||||
const text = 'read "@src/com" before merging';
|
||||
const cursorIndex = text.indexOf('"') + 9;
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex,
|
||||
})
|
||||
});
|
||||
expect(mention).toEqual({
|
||||
start: text.indexOf('@'),
|
||||
start: text.indexOf("@"),
|
||||
end: cursorIndex,
|
||||
query: 'src/com',
|
||||
})
|
||||
})
|
||||
query: "src/com",
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when cursor is outside the mention token', () => {
|
||||
const text = 'please review @src/components now'
|
||||
it("returns null when cursor is outside the mention token", () => {
|
||||
const text = "please review @src/components now";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
})
|
||||
expect(mention).toBeNull()
|
||||
})
|
||||
})
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFileMentionReplacement', () => {
|
||||
it('replaces only the active @query segment with a quoted relative path', () => {
|
||||
const text = 'open @src/com next'
|
||||
describe("applyFileMentionReplacement", () => {
|
||||
it("replaces only the active @query segment with a quoted relative path", () => {
|
||||
const text = "open @src/com next";
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 5, end: 13, query: 'src/com' },
|
||||
relativePath: 'src/components/chat.tsx',
|
||||
})
|
||||
expect(next).toBe('open "src/components/chat.tsx" next')
|
||||
})
|
||||
mention: { start: 5, end: 13, query: "src/com" },
|
||||
relativePath: "src/components/chat.tsx",
|
||||
});
|
||||
expect(next).toBe('open "src/components/chat.tsx" next');
|
||||
});
|
||||
|
||||
it('escapes double quotes in replacement path', () => {
|
||||
const text = '@foo'
|
||||
it("escapes double quotes in replacement path", () => {
|
||||
const text = "@foo";
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 0, end: 4, query: 'foo' },
|
||||
mention: { start: 0, end: 4, query: "foo" },
|
||||
relativePath: 'src/"quoted".ts',
|
||||
})
|
||||
expect(next).toBe('"src/\\"quoted\\".ts"')
|
||||
})
|
||||
})
|
||||
});
|
||||
expect(next).toBe('"src/\\"quoted\\".ts"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
export interface FileMentionRange {
|
||||
start: number
|
||||
end: number
|
||||
query: string
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface FindActiveFileMentionInput {
|
||||
text: string
|
||||
cursorIndex: number
|
||||
text: string;
|
||||
cursorIndex: number;
|
||||
}
|
||||
|
||||
interface ApplyFileMentionReplacementInput {
|
||||
text: string
|
||||
mention: FileMentionRange
|
||||
relativePath: string
|
||||
text: string;
|
||||
mention: FileMentionRange;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/;
|
||||
|
||||
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
|
||||
const beforeCursor = input.text.slice(0, clampedCursor)
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
|
||||
const beforeCursor = input.text.slice(0, clampedCursor);
|
||||
|
||||
for (
|
||||
let atIndex = beforeCursor.lastIndexOf('@');
|
||||
let atIndex = beforeCursor.lastIndexOf("@");
|
||||
atIndex >= 0;
|
||||
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
|
||||
atIndex = beforeCursor.lastIndexOf("@", atIndex - 1)
|
||||
) {
|
||||
const query = beforeCursor.slice(atIndex + 1)
|
||||
const query = beforeCursor.slice(atIndex + 1);
|
||||
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
start: atIndex,
|
||||
end: clampedCursor,
|
||||
query,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
|
||||
const safePath = input.relativePath.replace(/"/g, '\\"')
|
||||
const before = input.text.slice(0, input.mention.start)
|
||||
const after = input.text.slice(input.mention.end)
|
||||
return `${before}"${safePath}"${after}`
|
||||
const safePath = input.relativePath.replace(/"/g, '\\"');
|
||||
const before = input.text.slice(0, input.mention.start);
|
||||
const after = input.text.slice(input.mention.end);
|
||||
return `${before}"${safePath}"${after}`;
|
||||
}
|
||||
|
||||
@@ -13,4 +13,3 @@ describe("formatShortcut", () => {
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export type ShortcutKey =
|
||||
| "mod"
|
||||
| "shift"
|
||||
| "alt"
|
||||
| "ctrl"
|
||||
| "meta"
|
||||
| string;
|
||||
export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
|
||||
|
||||
export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
@@ -28,9 +22,7 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => symbols[k] ?? "");
|
||||
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
@@ -50,4 +42,3 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
.filter(Boolean)
|
||||
.join("+");
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 400,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 300,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
headerHeight: 44,
|
||||
viewportOffset: 500,
|
||||
viewportHeight: 300,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
headerHeight: 44,
|
||||
viewportOffset: 100,
|
||||
viewportHeight: 300,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
headerHeight: 44,
|
||||
viewportOffset: 0,
|
||||
viewportHeight: 0,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,9 +29,9 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
|
||||
it("decodes non-canonical base64url workspace IDs used by older links", () => {
|
||||
expect(
|
||||
decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")
|
||||
).toBe("/Users/moboudra/dev/paseo");
|
||||
expect(decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")).toBe(
|
||||
"/Users/moboudra/dev/paseo",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes file paths as base64url (no padding)", () => {
|
||||
@@ -49,7 +49,7 @@ describe("workspace route parsing", () => {
|
||||
|
||||
it("does not treat /tab routes as valid workspace routes", () => {
|
||||
expect(
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -64,8 +64,8 @@ describe("workspace route parsing", () => {
|
||||
it("parses workspace open intent from pathname query", () => {
|
||||
expect(
|
||||
parseHostWorkspaceOpenIntentFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||
)
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -86,7 +86,7 @@ describe("workspace route parsing", () => {
|
||||
|
||||
it("uses the plain workspace route when workspace context is provided", () => {
|
||||
expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,11 +88,7 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
|
||||
}
|
||||
|
||||
function isPathLikeWorkspaceIdentity(value: string): boolean {
|
||||
return (
|
||||
value.includes("/") ||
|
||||
value.includes("\\") ||
|
||||
/^[A-Za-z]:[\\/]/.test(value)
|
||||
);
|
||||
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceId(value: string): string {
|
||||
@@ -106,7 +102,7 @@ export type WorkspaceOpenIntent =
|
||||
| { kind: "draft"; draftId: string };
|
||||
|
||||
export function parseWorkspaceOpenIntent(
|
||||
value: string | null | undefined
|
||||
value: string | null | undefined,
|
||||
): WorkspaceOpenIntent | null {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (!normalized) {
|
||||
@@ -145,7 +141,7 @@ export function parseWorkspaceOpenIntent(
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceOpenIntentFromPathname(
|
||||
pathname: string
|
||||
pathname: string,
|
||||
): WorkspaceOpenIntent | null {
|
||||
const search = extractSearch(pathname);
|
||||
if (!search) {
|
||||
@@ -228,7 +224,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
}
|
||||
|
||||
export function parseHostAgentRouteFromPathname(
|
||||
pathname: string
|
||||
pathname: string,
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const pathOnly = stripSearchAndHash(pathname);
|
||||
const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
||||
@@ -251,7 +247,7 @@ export function parseHostAgentRouteFromPathname(
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceRouteFromPathname(
|
||||
pathname: string
|
||||
pathname: string,
|
||||
): { serverId: string; workspaceId: string } | null {
|
||||
const pathOnly = stripSearchAndHash(pathname);
|
||||
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
|
||||
@@ -272,10 +268,7 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceRoute(
|
||||
serverId: string,
|
||||
workspaceId: string
|
||||
): string {
|
||||
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
@@ -291,7 +284,7 @@ export function buildHostWorkspaceRoute(
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
workspaceId?: string
|
||||
workspaceId?: string,
|
||||
): string {
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (normalizedWorkspaceId) {
|
||||
@@ -310,9 +303,7 @@ export function buildHostAgentDetailRoute(
|
||||
if (!normalizedServerId || !normalizedAgentId) {
|
||||
return "/";
|
||||
}
|
||||
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(
|
||||
normalizedAgentId
|
||||
)}`;
|
||||
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`;
|
||||
}
|
||||
|
||||
export function buildHostRootRoute(serverId: string): string {
|
||||
@@ -347,10 +338,7 @@ export function buildHostSettingsRoute(serverId: string): string {
|
||||
return `${base}/settings`;
|
||||
}
|
||||
|
||||
export function mapPathnameToServer(
|
||||
pathname: string,
|
||||
nextServerId: string
|
||||
): string {
|
||||
export function mapPathnameToServer(pathname: string, nextServerId: string): string {
|
||||
const normalized = trimNonEmpty(nextServerId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
@@ -369,10 +357,7 @@ export function mapPathnameToServer(
|
||||
}
|
||||
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||
if (workspaceRoute) {
|
||||
return buildHostWorkspaceRoute(
|
||||
normalized,
|
||||
workspaceRoute.workspaceId
|
||||
);
|
||||
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
|
||||
}
|
||||
if (suffix.startsWith("agent/")) {
|
||||
return `${base}/${suffix}`;
|
||||
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
import { __setAttachmentStoreForTests } from "@/attachments/store";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
function createClipboardItem(params: {
|
||||
kind: string;
|
||||
type: string;
|
||||
file?: File | null;
|
||||
}) {
|
||||
function createClipboardItem(params: { kind: string; type: string; file?: File | null }) {
|
||||
return {
|
||||
kind: params.kind,
|
||||
type: params.type,
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ClipboardDataLike {
|
||||
export type ImageAttachmentFromFile = AttachmentMetadata;
|
||||
|
||||
export function collectImageFilesFromClipboardData(
|
||||
clipboardData?: ClipboardDataLike | null
|
||||
clipboardData?: ClipboardDataLike | null,
|
||||
): File[] {
|
||||
if (!clipboardData?.items) {
|
||||
return [];
|
||||
@@ -39,7 +39,7 @@ export function collectImageFilesFromClipboardData(
|
||||
}
|
||||
|
||||
export async function filesToImageAttachments(
|
||||
files: readonly File[]
|
||||
files: readonly File[],
|
||||
): Promise<ImageAttachmentFromFile[]> {
|
||||
const attachments = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
@@ -56,10 +56,8 @@ export async function filesToImageAttachments(
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return attachments.filter(
|
||||
(entry): entry is ImageAttachmentFromFile => entry !== null
|
||||
);
|
||||
return attachments.filter((entry): entry is ImageAttachmentFromFile => entry !== null);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ describe("parseInlinePathToken", () => {
|
||||
|
||||
describe("parseFileProtocolUrl", () => {
|
||||
it("parses file URLs with line fragments", () => {
|
||||
expect(
|
||||
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")
|
||||
).toEqual({
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx#L81",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 81,
|
||||
@@ -49,9 +47,7 @@ describe("parseFileProtocolUrl", () => {
|
||||
});
|
||||
|
||||
it("parses file URLs without line fragments", () => {
|
||||
expect(
|
||||
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")
|
||||
).toEqual({
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: undefined,
|
||||
@@ -60,9 +56,7 @@ describe("parseFileProtocolUrl", () => {
|
||||
});
|
||||
|
||||
it("parses windows file URLs and line ranges", () => {
|
||||
expect(
|
||||
parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")
|
||||
).toEqual({
|
||||
expect(parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")).toEqual({
|
||||
raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20",
|
||||
path: "C:/Users/test/project/src/app.tsx",
|
||||
lineStart: 12,
|
||||
@@ -72,9 +66,7 @@ describe("parseFileProtocolUrl", () => {
|
||||
|
||||
it("rejects non-file URLs and invalid ranges", () => {
|
||||
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
||||
expect(
|
||||
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")
|
||||
).toBeNull();
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +75,7 @@ describe("parseAssistantFileLink", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx#L33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
@@ -96,7 +88,7 @@ describe("parseAssistantFileLink", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx#L12-L20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
@@ -109,7 +101,7 @@ describe("parseAssistantFileLink", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "file:///tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
@@ -122,30 +114,26 @@ describe("parseAssistantFileLink", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects external URLs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")
|
||||
).toBeNull();
|
||||
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid line fragments", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
it("keeps relative file paths as file targets", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("packages/app/src/components/message.tsx")
|
||||
).toEqual({
|
||||
expect(normalizeInlinePathTarget("packages/app/src/components/message.tsx")).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
@@ -155,8 +143,8 @@ describe("normalizeInlinePathTarget", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget(
|
||||
"/Users/test/project/packages/app/src/components/message.tsx",
|
||||
"/Users/test/project"
|
||||
)
|
||||
"/Users/test/project",
|
||||
),
|
||||
).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
@@ -164,25 +152,21 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")
|
||||
).toEqual({
|
||||
expect(normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")).toEqual({
|
||||
directory: "/tmp",
|
||||
file: "/tmp/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cwd itself as the workspace root directory", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")
|
||||
).toEqual({
|
||||
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
|
||||
directory: ".",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trailing-slash paths as directories", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project")
|
||||
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project"),
|
||||
).toEqual({
|
||||
directory: "packages/app",
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
|
||||
export function parseAssistantFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions = {}
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): InlinePathTarget | null {
|
||||
const fileUrlTarget = parseFileProtocolUrl(value);
|
||||
if (fileUrlTarget) {
|
||||
@@ -217,7 +217,7 @@ export function parseAssistantFileLink(
|
||||
|
||||
export function normalizeInlinePathTarget(
|
||||
rawPath: string,
|
||||
cwd?: string
|
||||
cwd?: string,
|
||||
): NormalizedInlinePathTarget | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
@@ -268,15 +268,11 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
|
||||
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareWorkspaceRoot = normalizePathForCompare(
|
||||
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/"
|
||||
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/",
|
||||
);
|
||||
const comparePrefix =
|
||||
compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
||||
const comparePrefix = compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
||||
|
||||
return (
|
||||
comparePath === compareWorkspaceRoot ||
|
||||
comparePath.startsWith(comparePrefix)
|
||||
);
|
||||
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
||||
}
|
||||
|
||||
function normalizeFileUrlPath(pathname: string): string | null {
|
||||
@@ -314,11 +310,7 @@ function normalizePathInput(value: string | undefined): string | null {
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (
|
||||
!normalizedCwd ||
|
||||
!isAbsolutePath(pathValue) ||
|
||||
!isAbsolutePath(normalizedCwd)
|
||||
) {
|
||||
if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,14 @@ import { getMarkdownListMarker } from "./markdown-list";
|
||||
|
||||
describe("getMarkdownListMarker", () => {
|
||||
it("returns a bullet marker for unordered list items", () => {
|
||||
expect(
|
||||
getMarkdownListMarker(
|
||||
{ index: 0 },
|
||||
[{ type: "bullet_list" }]
|
||||
)
|
||||
).toEqual({
|
||||
expect(getMarkdownListMarker({ index: 0 }, [{ type: "bullet_list" }])).toEqual({
|
||||
isOrdered: false,
|
||||
marker: "•",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns numbered markers for ordered list items", () => {
|
||||
expect(
|
||||
getMarkdownListMarker(
|
||||
{ index: 1, markup: "." },
|
||||
[{ type: "ordered_list" }]
|
||||
)
|
||||
).toEqual({
|
||||
expect(getMarkdownListMarker({ index: 1, markup: "." }, [{ type: "ordered_list" }])).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "2.",
|
||||
});
|
||||
@@ -28,10 +18,9 @@ describe("getMarkdownListMarker", () => {
|
||||
|
||||
it("respects ordered list start attribute", () => {
|
||||
expect(
|
||||
getMarkdownListMarker(
|
||||
{ index: 2, markup: ")" },
|
||||
[{ type: "ordered_list", attributes: { start: "5" } }]
|
||||
)
|
||||
getMarkdownListMarker({ index: 2, markup: ")" }, [
|
||||
{ type: "ordered_list", attributes: { start: "5" } },
|
||||
]),
|
||||
).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "7)",
|
||||
@@ -40,10 +29,10 @@ describe("getMarkdownListMarker", () => {
|
||||
|
||||
it("prefers the nearest list ancestor in nested lists", () => {
|
||||
expect(
|
||||
getMarkdownListMarker(
|
||||
{ index: 0, markup: "." },
|
||||
[{ type: "ordered_list" }, { type: "bullet_list" }]
|
||||
)
|
||||
getMarkdownListMarker({ index: 0, markup: "." }, [
|
||||
{ type: "ordered_list" },
|
||||
{ type: "bullet_list" },
|
||||
]),
|
||||
).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "1.",
|
||||
|
||||
@@ -25,8 +25,7 @@ function toParentNodes(parent: unknown): MarkdownNode[] {
|
||||
|
||||
function getNearestListParent(parent: unknown): MarkdownNode | undefined {
|
||||
return toParentNodes(parent).find(
|
||||
(ancestor) =>
|
||||
ancestor?.type === "ordered_list" || ancestor?.type === "bullet_list"
|
||||
(ancestor) => ancestor?.type === "ordered_list" || ancestor?.type === "bullet_list",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +60,10 @@ function parseOrderedListStart(node: MarkdownNode): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getMarkdownListMarker(node: MarkdownNode, parent: unknown): {
|
||||
export function getMarkdownListMarker(
|
||||
node: MarkdownNode,
|
||||
parent: unknown,
|
||||
): {
|
||||
isOrdered: boolean;
|
||||
marker: string;
|
||||
} {
|
||||
|
||||
@@ -10,15 +10,13 @@ import {
|
||||
|
||||
describe("buildNewAgentRoute", () => {
|
||||
it("falls back to server workspace route with dot workspace when no working directory is provided", () => {
|
||||
expect(buildNewAgentRoute("srv-1", undefined)).toBe(
|
||||
"/h/srv-1/workspace/Lg"
|
||||
);
|
||||
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/workspace/Lg");
|
||||
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/Lg");
|
||||
});
|
||||
|
||||
it("encodes the working directory as a workspace path segment", () => {
|
||||
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
|
||||
"/h/srv-1/workspace/L1VzZXJzL21lL2Rldi9wYXNlbw"
|
||||
"/h/srv-1/workspace/L1VzZXJzL21lL2Rldi9wYXNlbw",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,15 +27,13 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
});
|
||||
|
||||
it("falls back to repo root when checkout metadata is unavailable", () => {
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe(
|
||||
"/repo"
|
||||
);
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe("/repo");
|
||||
});
|
||||
|
||||
it("supports windows-style paseo worktree paths without checkout metadata", () => {
|
||||
expect(
|
||||
resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)
|
||||
).toBe("C:\\Users\\me\\repo");
|
||||
expect(resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)).toBe(
|
||||
"C:\\Users\\me\\repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the main repo root for paseo-owned worktrees", () => {
|
||||
@@ -47,7 +43,7 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
} as CheckoutStatusPayload;
|
||||
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
|
||||
"/repo/main"
|
||||
"/repo/main",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/workspace/L3JlcG8?open=agent%3Aagent-2",
|
||||
selectedAgentId: "srv-9:agent-9",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-2",
|
||||
@@ -93,7 +89,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
selectedAgentId: "srv-1:agent-7",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-7",
|
||||
@@ -104,7 +100,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
})
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function parseAgentKey(
|
||||
key: string | null | undefined
|
||||
key: string | null | undefined,
|
||||
): { serverId: string; agentId: string } | null {
|
||||
if (!key) {
|
||||
return null;
|
||||
@@ -36,10 +36,7 @@ export function resolveSelectedAgentForNewAgent(input: {
|
||||
return { serverId: workspaceRoute.serverId, agentId };
|
||||
}
|
||||
}
|
||||
return (
|
||||
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||
parseAgentKey(input.selectedAgentId)
|
||||
);
|
||||
return parseHostAgentRouteFromPathname(input.pathname) ?? parseAgentKey(input.selectedAgentId);
|
||||
}
|
||||
|
||||
function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
|
||||
@@ -60,7 +57,7 @@ function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
checkout: CheckoutStatusPayload | null
|
||||
checkout: CheckoutStatusPayload | null,
|
||||
): string {
|
||||
const explicitMainRepoRoot = checkout?.isPaseoOwnedWorktree
|
||||
? checkout.mainRepoRoot?.trim() || null
|
||||
@@ -72,10 +69,7 @@ export function resolveNewAgentWorkingDir(
|
||||
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
|
||||
}
|
||||
|
||||
export function buildNewAgentRoute(
|
||||
serverId: string,
|
||||
workingDir?: string | null
|
||||
): string {
|
||||
export function buildNewAgentRoute(serverId: string, workingDir?: string | null): string {
|
||||
const trimmedWorkingDir = workingDir?.trim();
|
||||
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
|
||||
describe("resolveNotificationTarget", () => {
|
||||
it("extracts non-empty server and agent ids", () => {
|
||||
@@ -11,7 +8,7 @@ describe("resolveNotificationTarget", () => {
|
||||
resolveNotificationTarget({
|
||||
serverId: " server-123 ",
|
||||
agentId: " agent-456 ",
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
@@ -40,13 +37,13 @@ describe("buildNotificationRoute", () => {
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "/tmp/repo",
|
||||
})
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1");
|
||||
});
|
||||
|
||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
|
||||
"/h/srv-1/agent/agent-1"
|
||||
"/h/srv-1/agent/agent-1",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,7 +61,7 @@ describe("buildNotificationRoute", () => {
|
||||
buildNotificationRoute({
|
||||
serverId: "srv/with/slash",
|
||||
agentId: "agent with space",
|
||||
})
|
||||
}),
|
||||
).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
|
||||
function readNonEmptyString(
|
||||
data: NotificationData,
|
||||
key: string
|
||||
): string | null {
|
||||
function readNonEmptyString(data: NotificationData, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -26,9 +23,7 @@ export function resolveNotificationTarget(data: NotificationData): {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
workspaceId:
|
||||
readNonEmptyString(data, "workspaceId") ??
|
||||
readNonEmptyString(data, "cwd"),
|
||||
workspaceId: readNonEmptyString(data, "workspaceId") ?? readNonEmptyString(data, "cwd"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ async function loadModuleForPlatform(
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
},
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||
@@ -107,7 +107,10 @@ describe("sendOsNotification", () => {
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: MockNotificationOptions,
|
||||
) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
@@ -122,8 +125,7 @@ describe("sendOsNotification", () => {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } =
|
||||
await loadModuleForPlatform("web");
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -159,7 +161,10 @@ describe("sendOsNotification", () => {
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: MockNotificationOptions,
|
||||
) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
@@ -209,7 +214,10 @@ describe("sendOsNotification", () => {
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: MockNotificationOptions,
|
||||
) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { Platform } from "react-native";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -51,7 +48,7 @@ function getWebNotificationConstructor(): {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
}
|
||||
},
|
||||
): unknown;
|
||||
} | null {
|
||||
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
|
||||
@@ -75,7 +72,7 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
permissionRequest = Promise.resolve(
|
||||
NotificationConstructor.requestPermission
|
||||
? NotificationConstructor.requestPermission()
|
||||
: "denied"
|
||||
: "denied",
|
||||
).then((permission) => permission === "granted");
|
||||
const result = await permissionRequest;
|
||||
permissionRequest = null;
|
||||
@@ -89,9 +86,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
return await ensureNotificationPermission();
|
||||
}
|
||||
|
||||
function hasNotificationClickTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
}
|
||||
@@ -102,9 +97,7 @@ function getWebNotificationIconUrl(): string | undefined {
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = Asset.fromModule(
|
||||
require("../../assets/images/notification-icon.png")
|
||||
);
|
||||
const asset = Asset.fromModule(require("../../assets/images/notification-icon.png"));
|
||||
notificationIconUrl = asset.uri ?? null;
|
||||
} catch {
|
||||
notificationIconUrl = null;
|
||||
@@ -115,8 +108,7 @@ function getWebNotificationIconUrl(): string | undefined {
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
.CustomEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }).CustomEvent;
|
||||
|
||||
if (typeof dispatch !== "function" || !CustomEventConstructor) {
|
||||
return false;
|
||||
@@ -127,14 +119,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
|
||||
{
|
||||
detail,
|
||||
cancelable: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
return dispatch(event) === false;
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
||||
const route = buildNotificationRoute(data);
|
||||
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
|
||||
.location;
|
||||
@@ -152,7 +142,7 @@ function fallbackNavigateToNotificationTarget(
|
||||
|
||||
function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined
|
||||
data: Record<string, unknown> | undefined,
|
||||
): void {
|
||||
notification.onclick = () => {
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
@@ -162,9 +152,7 @@ function attachWebClickHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendOsNotification(
|
||||
payload: OsNotificationPayload
|
||||
): Promise<boolean> {
|
||||
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
|
||||
// Mobile/native notifications should be remote push only.
|
||||
if (Platform.OS !== "web") {
|
||||
return false;
|
||||
|
||||
@@ -5,7 +5,13 @@ import { mergePendingCreateImages } from "./pending-create-images";
|
||||
function userMessage(params: {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: Array<{ id: string; storageType: "native-file"; storageKey: string; mimeType: string; createdAt: number }>;
|
||||
images?: Array<{
|
||||
id: string;
|
||||
storageType: "native-file";
|
||||
storageKey: string;
|
||||
mimeType: string;
|
||||
createdAt: number;
|
||||
}>;
|
||||
}): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -71,9 +77,7 @@ describe("mergePendingCreateImages", () => {
|
||||
|
||||
it("does not overwrite existing user message images", () => {
|
||||
const existingImages = buildImage("existing");
|
||||
const streamItems = [
|
||||
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
|
||||
];
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello", images: existingImages })];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
clientMessageId: "msg-1",
|
||||
|
||||
@@ -16,7 +16,7 @@ export function mergePendingCreateImages({
|
||||
}
|
||||
|
||||
const targetIndex = streamItems.findIndex(
|
||||
(item) => item.kind === "user_message" && item.id === clientMessageId
|
||||
(item) => item.kind === "user_message" && item.id === clientMessageId,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return streamItems;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
describe("projectDisplayNameFromProjectId", () => {
|
||||
it("shows owner and repo for GitHub remote ids", () => {
|
||||
expect(projectDisplayNameFromProjectId("remote:github.com/getpaseo/paseo")).toBe(
|
||||
"getpaseo/paseo"
|
||||
"getpaseo/paseo",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@ export function projectDisplayNameFromProjectId(projectId: string): string {
|
||||
return segments[segments.length - 1] || projectId;
|
||||
}
|
||||
|
||||
export function projectIconPlaceholderLabelFromDisplayName(
|
||||
displayName: string
|
||||
): string {
|
||||
export function projectIconPlaceholderLabelFromDisplayName(displayName: string): string {
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
if (!trimmedDisplayName) {
|
||||
return "";
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveProjectPlacementFromCwd,
|
||||
resolveProjectPlacement,
|
||||
} from "./project-placement";
|
||||
import { deriveProjectPlacementFromCwd, resolveProjectPlacement } from "./project-placement";
|
||||
|
||||
describe("project-placement", () => {
|
||||
it("derives fallback placement from cwd", () => {
|
||||
@@ -15,15 +12,11 @@ describe("project-placement", () => {
|
||||
});
|
||||
|
||||
it("normalizes paseo worktree paths into the parent repo key", () => {
|
||||
const placement = deriveProjectPlacementFromCwd(
|
||||
"/Users/test/repo/.paseo/worktrees/feature-x"
|
||||
);
|
||||
const placement = deriveProjectPlacementFromCwd("/Users/test/repo/.paseo/worktrees/feature-x");
|
||||
|
||||
expect(placement.projectKey).toBe("/Users/test/repo");
|
||||
expect(placement.projectName).toBe("repo");
|
||||
expect(placement.checkout.cwd).toBe(
|
||||
"/Users/test/repo/.paseo/worktrees/feature-x"
|
||||
);
|
||||
expect(placement.checkout.cwd).toBe("/Users/test/repo/.paseo/worktrees/feature-x");
|
||||
});
|
||||
|
||||
it("prefers an existing placement when present", () => {
|
||||
|
||||
@@ -19,10 +19,7 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
|
||||
},
|
||||
};
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string>
|
||||
): string {
|
||||
function renderTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? "");
|
||||
}
|
||||
|
||||
@@ -37,4 +34,3 @@ export function buildProviderCommand(input: {
|
||||
}
|
||||
return renderTemplate(template, { sessionId: input.sessionId });
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ type ScrollInvestigationStore = {
|
||||
| "wheelAttach"
|
||||
| "wheelDetach"
|
||||
| "inputChange"
|
||||
| "keyPress"
|
||||
| "keyPress",
|
||||
) => void;
|
||||
snapshot: () => {
|
||||
listeners: {
|
||||
@@ -92,11 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]";
|
||||
function shouldInstall(): boolean {
|
||||
const runtime = globalThis as ScrollInvestigationGlobal;
|
||||
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
return (
|
||||
Platform.OS === "web" &&
|
||||
isDev &&
|
||||
!runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__
|
||||
);
|
||||
return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
|
||||
}
|
||||
|
||||
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
|
||||
@@ -137,8 +133,7 @@ function describeEventTarget(target: EventTarget): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
const ctorName = (target as { constructor?: { name?: string } }).constructor
|
||||
?.name;
|
||||
const ctorName = (target as { constructor?: { name?: string } }).constructor?.name;
|
||||
return ctorName || "unknown-target";
|
||||
}
|
||||
|
||||
@@ -164,10 +159,7 @@ function inferCallsite(): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function ensureListenerStats(
|
||||
map: Map<string, ListenerStats>,
|
||||
type: string
|
||||
): ListenerStats {
|
||||
function ensureListenerStats(map: Map<string, ListenerStats>, type: string): ListenerStats {
|
||||
const existing = map.get(type);
|
||||
if (existing) {
|
||||
return existing;
|
||||
@@ -179,7 +171,7 @@ function ensureListenerStats(
|
||||
|
||||
function ensureComponentStats(
|
||||
map: Map<string, ComponentStats>,
|
||||
componentId: string
|
||||
componentId: string,
|
||||
): ComponentStats {
|
||||
const existing = map.get(componentId);
|
||||
if (existing) {
|
||||
@@ -216,10 +208,7 @@ export function installScrollJankInvestigation(): void {
|
||||
const targetIds = new WeakMap<object, number>();
|
||||
const listenerIds = new WeakMap<object, number>();
|
||||
const activeListenerKeys = new Set<string>();
|
||||
const activeListenerMeta = new Map<
|
||||
string,
|
||||
{ type: string; target: string }
|
||||
>();
|
||||
const activeListenerMeta = new Map<string, { type: string; target: string }>();
|
||||
const listenerStatsByType = new Map<string, ListenerStats>();
|
||||
const listenerCallsiteCount = new Map<string, number>();
|
||||
const componentStatsById = new Map<string, ComponentStats>();
|
||||
@@ -276,7 +265,7 @@ export function installScrollJankInvestigation(): void {
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): string {
|
||||
return [
|
||||
getTargetId(target),
|
||||
@@ -290,7 +279,7 @@ export function installScrollJankInvestigation(): void {
|
||||
this: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void {
|
||||
nativeAddEventListener.call(this, type, listener as any, options as any);
|
||||
if (!listener) {
|
||||
@@ -312,10 +301,7 @@ export function installScrollJankInvestigation(): void {
|
||||
if (TRACKED_EVENT_TYPES.has(type)) {
|
||||
const callsite = inferCallsite();
|
||||
const metricKey = `${type} :: ${callsite}`;
|
||||
listenerCallsiteCount.set(
|
||||
metricKey,
|
||||
(listenerCallsiteCount.get(metricKey) ?? 0) + 1
|
||||
);
|
||||
listenerCallsiteCount.set(metricKey, (listenerCallsiteCount.get(metricKey) ?? 0) + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -323,7 +309,7 @@ export function installScrollJankInvestigation(): void {
|
||||
this: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: EventListenerOptions | boolean
|
||||
options?: EventListenerOptions | boolean,
|
||||
): void {
|
||||
nativeRemoveEventListener.call(this, type, listener as any, options as any);
|
||||
if (!listener) {
|
||||
@@ -343,10 +329,8 @@ export function installScrollJankInvestigation(): void {
|
||||
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
|
||||
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
|
||||
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
|
||||
const nativeRequestAnimationFrame =
|
||||
globalThis.requestAnimationFrame.bind(globalThis);
|
||||
const nativeCancelAnimationFrame =
|
||||
globalThis.cancelAnimationFrame.bind(globalThis);
|
||||
const nativeRequestAnimationFrame = globalThis.requestAnimationFrame.bind(globalThis);
|
||||
const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame.bind(globalThis);
|
||||
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
timerStats.timeout.created += 1;
|
||||
@@ -545,7 +529,7 @@ export function installScrollJankInvestigation(): void {
|
||||
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
|
||||
console.log(
|
||||
`${SOURCE_LABEL} installed`,
|
||||
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()"
|
||||
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -573,7 +557,7 @@ export function markScrollInvestigationEvent(
|
||||
| "wheelAttach"
|
||||
| "wheelDetach"
|
||||
| "inputChange"
|
||||
| "keyPress"
|
||||
| "keyPress",
|
||||
): void {
|
||||
if (!shouldInstall()) {
|
||||
return;
|
||||
|
||||
@@ -12,5 +12,5 @@ export function markScrollInvestigationRender(_componentId: string): void {}
|
||||
|
||||
export function markScrollInvestigationEvent(
|
||||
_componentId: string,
|
||||
_event: ScrollInvestigationEvent
|
||||
_event: ScrollInvestigationEvent,
|
||||
): void {}
|
||||
|
||||
@@ -41,13 +41,13 @@ describe("server-info-capabilities", () => {
|
||||
getVoiceReadinessState({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})
|
||||
}),
|
||||
).toEqual(capabilities.voice?.dictation);
|
||||
expect(
|
||||
getVoiceReadinessState({
|
||||
serverInfo,
|
||||
mode: "voice",
|
||||
})
|
||||
}),
|
||||
).toEqual(capabilities.voice?.voice);
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("server-info-capabilities", () => {
|
||||
resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("server-info-capabilities", () => {
|
||||
resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})
|
||||
}),
|
||||
).toBe("Dictation models are still downloading.");
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("server-info-capabilities", () => {
|
||||
resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ export function getShortcutOs(): ShortcutOs {
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
const isApple =
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
|
||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("deriveSidebarStateBucket", () => {
|
||||
pendingPermissionCount: 1,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
})
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("deriveSidebarStateBucket", () => {
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
})
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("deriveSidebarStateBucket", () => {
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
})
|
||||
}),
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("deriveSidebarStateBucket", () => {
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
})
|
||||
}),
|
||||
).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
|
||||
export type SidebarAttentionReason =
|
||||
| "finished"
|
||||
| "error"
|
||||
| "permission"
|
||||
| null
|
||||
| undefined;
|
||||
export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
export type SidebarStateBucket =
|
||||
| "needs_input"
|
||||
| "failed"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "done";
|
||||
export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done";
|
||||
|
||||
export function deriveSidebarStateBucket(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 3, y: 2 },
|
||||
})
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 8, y: 8 },
|
||||
})
|
||||
}),
|
||||
).toBe("cancel_long_press");
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 2, y: 7 },
|
||||
})
|
||||
}),
|
||||
).toBe("vertical_scroll");
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 5, y: 7 },
|
||||
})
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: -10, y: 2 },
|
||||
})
|
||||
}),
|
||||
).toBe("horizontal_swipe");
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 0, y: 9 },
|
||||
})
|
||||
}),
|
||||
).toBe("start_drag");
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("decideLongPressMove", () => {
|
||||
didStartDrag: true,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 20, y: 20 },
|
||||
})
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -90,21 +90,21 @@ describe("shouldOpenContextMenuOnPressOut", () => {
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: true,
|
||||
didStartDrag: false,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: false,
|
||||
didStartDrag: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: true,
|
||||
didStartDrag: true,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,150 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
isSidebarProjectFlattened,
|
||||
} from './sidebar-project-row-model'
|
||||
} from "./sidebar-project-row-model";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: 'srv:/repo',
|
||||
serverId: 'srv',
|
||||
workspaceId: '/repo',
|
||||
workspaceKind: 'directory',
|
||||
name: 'paseo',
|
||||
workspaceKey: "srv:/repo",
|
||||
serverId: "srv",
|
||||
workspaceId: "/repo",
|
||||
workspaceKind: "directory",
|
||||
name: "paseo",
|
||||
activityAt: null,
|
||||
statusBucket: 'done',
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
...overrides,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: 'project-1',
|
||||
projectName: 'paseo',
|
||||
projectKind: 'git',
|
||||
iconWorkingDir: '/repo',
|
||||
statusBucket: 'done',
|
||||
projectKey: "project-1",
|
||||
projectName: "paseo",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 1,
|
||||
latestActivityAt: null,
|
||||
workspaces: [workspace()],
|
||||
...overrides,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSidebarProjectRowModel', () => {
|
||||
it('flattens non-git projects with one workspace into a direct workspace row model', () => {
|
||||
describe("buildSidebarProjectRowModel", () => {
|
||||
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: '/repo/non-git',
|
||||
workspaceKind: 'directory',
|
||||
statusBucket: 'running',
|
||||
})
|
||||
workspaceId: "/repo/non-git",
|
||||
workspaceKind: "directory",
|
||||
statusBucket: "running",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'non_git',
|
||||
projectKind: "non_git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'workspace_link',
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
trailingAction: 'none',
|
||||
})
|
||||
})
|
||||
trailingAction: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it('marks flattened non-git project rows as selected when their workspace is active', () => {
|
||||
it("marks flattened non-git project rows as selected when their workspace is active", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
serverId: 'srv-2',
|
||||
workspaceId: '/repo/non-git',
|
||||
})
|
||||
serverId: "srv-2",
|
||||
workspaceId: "/repo/non-git",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'non_git',
|
||||
projectKind: "non_git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
serverId: 'srv-2',
|
||||
serverId: "srv-2",
|
||||
activeWorkspaceSelection: {
|
||||
serverId: 'srv-2',
|
||||
workspaceId: '/repo/non-git',
|
||||
serverId: "srv-2",
|
||||
workspaceId: "/repo/non-git",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'workspace_link',
|
||||
kind: "workspace_link",
|
||||
selected: true,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('flattens git projects with a single workspace and keeps the new worktree action', () => {
|
||||
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: '/repo/main',
|
||||
workspaceKind: 'local_checkout',
|
||||
})
|
||||
workspaceId: "/repo/main",
|
||||
workspaceKind: "local_checkout",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'git',
|
||||
projectKind: "git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: true,
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'workspace_link',
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
trailingAction: 'new_worktree',
|
||||
})
|
||||
})
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps multi-workspace git projects as expandable sections with a new worktree action', () => {
|
||||
it("keeps multi-workspace git projects as expandable sections with a new worktree action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'git',
|
||||
projectKind: "git",
|
||||
workspaces: [
|
||||
workspace({ workspaceId: '/repo/main', workspaceKind: 'local_checkout' }),
|
||||
workspace({ workspaceId: '/repo/feature', workspaceKind: 'worktree' }),
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }),
|
||||
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
|
||||
],
|
||||
}),
|
||||
collapsed: true,
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'project_section',
|
||||
chevron: 'expand',
|
||||
trailingAction: 'new_worktree',
|
||||
})
|
||||
})
|
||||
})
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSidebarProjectFlattened', () => {
|
||||
it('returns true for single-workspace projects regardless of kind', () => {
|
||||
expect(isSidebarProjectFlattened(project({ projectKind: 'git', workspaces: [workspace()] }))).toBe(true)
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true for single-workspace projects regardless of kind", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: 'non_git', workspaces: [workspace()] }))
|
||||
).toBe(true)
|
||||
})
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for multi-workspace projects', () => {
|
||||
it("returns false for multi-workspace projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(
|
||||
project({
|
||||
workspaces: [workspace({ workspaceId: '/repo/main' }), workspace({ workspaceId: '/repo/feat' })],
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
workspaces: [
|
||||
workspace({ workspaceId: "/repo/main" }),
|
||||
workspace({ workspaceId: "/repo/feat" }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
export interface SidebarProjectWorkspaceLinkRowModel {
|
||||
kind: 'workspace_link'
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
chevron: null
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
kind: "workspace_link";
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
chevron: null;
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
kind: 'project_section'
|
||||
chevron: 'expand' | 'collapse'
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
kind: "project_section";
|
||||
chevron: "expand" | "collapse";
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
|
||||
export type SidebarProjectRowModel =
|
||||
| SidebarProjectWorkspaceLinkRowModel
|
||||
| SidebarProjectSectionRowModel
|
||||
| SidebarProjectSectionRowModel;
|
||||
|
||||
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
|
||||
return project.workspaces.length === 1
|
||||
return project.workspaces.length === 1;
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry
|
||||
collapsed: boolean
|
||||
serverId?: string | null
|
||||
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
serverId?: string | null;
|
||||
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null;
|
||||
}): SidebarProjectRowModel {
|
||||
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
|
||||
? (input.project.workspaces[0] ?? null)
|
||||
: null
|
||||
: null;
|
||||
const selected =
|
||||
flattenedWorkspace !== null &&
|
||||
Boolean(input.serverId) &&
|
||||
input.activeWorkspaceSelection?.serverId === input.serverId &&
|
||||
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId
|
||||
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId;
|
||||
|
||||
if (flattenedWorkspace) {
|
||||
return {
|
||||
kind: 'workspace_link',
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected,
|
||||
chevron: null,
|
||||
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
|
||||
}
|
||||
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'project_section',
|
||||
chevron: input.collapsed ? 'expand' : 'collapse',
|
||||
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
|
||||
}
|
||||
kind: "project_section",
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("hasVisibleOrderChanged", () => {
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b", "c", "d"],
|
||||
reorderedVisibleKeys: ["a", "b", "c"],
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("hasVisibleOrderChanged", () => {
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b", "c", "d"],
|
||||
reorderedVisibleKeys: ["b", "a", "c"],
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("hasVisibleOrderChanged", () => {
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b"],
|
||||
reorderedVisibleKeys: ["a", "c"],
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ describe("mergeWithRemainder", () => {
|
||||
mergeWithRemainder({
|
||||
currentOrder: ["a", "x", "b", "y"],
|
||||
reorderedVisibleKeys: ["b", "a"],
|
||||
})
|
||||
}),
|
||||
).toEqual(["b", "a", "x", "y"]);
|
||||
});
|
||||
|
||||
@@ -46,8 +46,7 @@ describe("mergeWithRemainder", () => {
|
||||
mergeWithRemainder({
|
||||
currentOrder: ["stale", "hidden"],
|
||||
reorderedVisibleKeys: [],
|
||||
})
|
||||
}),
|
||||
).toEqual(["stale", "hidden"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
export function mergeWithRemainder(input: {
|
||||
currentOrder: string[]
|
||||
reorderedVisibleKeys: string[]
|
||||
currentOrder: string[];
|
||||
reorderedVisibleKeys: string[];
|
||||
}): string[] {
|
||||
const reorderedSet = new Set(input.reorderedVisibleKeys)
|
||||
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key))
|
||||
return [...input.reorderedVisibleKeys, ...remainder]
|
||||
const reorderedSet = new Set(input.reorderedVisibleKeys);
|
||||
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key));
|
||||
return [...input.reorderedVisibleKeys, ...remainder];
|
||||
}
|
||||
|
||||
export function hasVisibleOrderChanged(input: {
|
||||
currentOrder: string[]
|
||||
reorderedVisibleKeys: string[]
|
||||
currentOrder: string[];
|
||||
reorderedVisibleKeys: string[];
|
||||
}): boolean {
|
||||
const visibleSet = new Set(input.reorderedVisibleKeys)
|
||||
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key))
|
||||
const visibleSet = new Set(input.reorderedVisibleKeys);
|
||||
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key));
|
||||
if (currentVisible.length !== input.reorderedVisibleKeys.length) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key)
|
||||
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
|
||||
|
||||
@@ -56,7 +59,9 @@ describe("buildSidebarShortcutModel", () => {
|
||||
});
|
||||
|
||||
it("limits shortcuts to 9", () => {
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) =>
|
||||
workspace("s", `/repo/w${index + 1}`),
|
||||
);
|
||||
const projects = [project("p", workspaces)];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { isSidebarProjectFlattened } from './sidebar-project-row-model'
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string
|
||||
workspaceId: string
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SidebarShortcutModel {
|
||||
visibleTargets: SidebarShortcutWorkspaceTarget[]
|
||||
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
visibleTargets: SidebarShortcutWorkspaceTarget[];
|
||||
shortcutTargets: SidebarShortcutWorkspaceTarget[];
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
}
|
||||
|
||||
function createShortcutTarget(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
): SidebarShortcutWorkspaceTarget {
|
||||
function createShortcutTarget(workspace: SidebarWorkspaceEntry): SidebarShortcutWorkspaceTarget {
|
||||
return {
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarShortcutModel(input: {
|
||||
projects: SidebarProjectEntry[]
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
shortcutLimit?: number
|
||||
projects: SidebarProjectEntry[];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutLimit?: number;
|
||||
}): SidebarShortcutModel {
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||
const visibleTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
|
||||
const visibleTargets: SidebarShortcutWorkspaceTarget[] = [];
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
||||
|
||||
for (const project of input.projects) {
|
||||
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const workspace of project.workspaces) {
|
||||
visibleTargets.push(createShortcutTarget(workspace))
|
||||
visibleTargets.push(createShortcutTarget(workspace));
|
||||
|
||||
if (shortcutTargets.length >= maxShortcuts) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortcutNumber = shortcutTargets.length + 1
|
||||
shortcutTargets.push(createShortcutTarget(workspace))
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
const shortcutNumber = shortcutTargets.length + 1;
|
||||
shortcutTargets.push(createShortcutTarget(workspace));
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||
return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey };
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ interface PaneCandidate {
|
||||
export function findAdjacentPane(
|
||||
root: SplitNode,
|
||||
focusedPaneId: string,
|
||||
direction: "left" | "right" | "up" | "down"
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
): string | null {
|
||||
const panes = collectPaneBounds(root, {
|
||||
left: ROOT_MIN,
|
||||
@@ -168,7 +168,7 @@ function buildCandidate(input: {
|
||||
|
||||
function collectPaneBounds(
|
||||
node: SplitNode,
|
||||
bounds: { left: number; top: number; right: number; bottom: number }
|
||||
bounds: { left: number; top: number; right: number; bottom: number },
|
||||
): PaneBounds[] {
|
||||
if (node.kind === "pane") {
|
||||
return [
|
||||
@@ -203,7 +203,7 @@ function collectPaneBounds(
|
||||
top: bounds.top,
|
||||
right: childRight,
|
||||
bottom: bounds.bottom,
|
||||
})
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ function collectPaneBounds(
|
||||
top: childTop,
|
||||
right: bounds.right,
|
||||
bottom: childBottom,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export type StatusLoaderBucket =
|
||||
| "needs_input"
|
||||
| "failed"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "done";
|
||||
export type StatusLoaderBucket = "needs_input" | "failed" | "running" | "attention" | "done";
|
||||
|
||||
export function shouldRenderSyncedStatusLoader(input: {
|
||||
bucket: StatusLoaderBucket | null | undefined;
|
||||
|
||||
@@ -18,18 +18,14 @@ describe("terminal-attach", () => {
|
||||
});
|
||||
|
||||
it("matches retryable attach errors", () => {
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "stream ended before ack" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "permission denied" })
|
||||
).toBe(false);
|
||||
expect(isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isTerminalAttachRetryableError({ message: "stream ended before ack" })).toBe(true);
|
||||
expect(isTerminalAttachRetryableError({ message: "permission denied" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and updates resume offsets monotonically", () => {
|
||||
@@ -46,7 +42,7 @@ describe("terminal-attach", () => {
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetStore,
|
||||
})
|
||||
}),
|
||||
).toBeUndefined();
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
@@ -58,7 +54,7 @@ describe("terminal-attach", () => {
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetStore,
|
||||
})
|
||||
}),
|
||||
).toBe(8);
|
||||
|
||||
// Stale offsets must not move resume backwards.
|
||||
@@ -71,7 +67,7 @@ describe("terminal-attach", () => {
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetStore,
|
||||
})
|
||||
}),
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
@@ -81,7 +77,7 @@ describe("terminal-attach", () => {
|
||||
promise: Promise.resolve("ok"),
|
||||
timeoutMs: 50,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
}),
|
||||
).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
@@ -91,7 +87,7 @@ describe("terminal-attach", () => {
|
||||
promise: new Promise<string>(() => {}),
|
||||
timeoutMs: 10,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,15 +53,13 @@ export function updateTerminalResumeOffset(input: {
|
||||
|
||||
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
||||
const clampedAttempt = Math.max(0, input.attempt);
|
||||
const exponentialDelay = 250 * (2 ** clampedAttempt);
|
||||
const exponentialDelay = 250 * 2 ** clampedAttempt;
|
||||
return Math.min(2_000, exponentialDelay);
|
||||
}
|
||||
|
||||
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
|
||||
const normalized = input.message.toLowerCase();
|
||||
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern)
|
||||
);
|
||||
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
}
|
||||
|
||||
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
|
||||
@@ -77,9 +75,12 @@ export async function withPromiseTimeout<T>(input: {
|
||||
}): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(input.timeoutMessage));
|
||||
}, Math.max(0, input.timeoutMs));
|
||||
timeoutHandle = setTimeout(
|
||||
() => {
|
||||
reject(new Error(input.timeoutMessage));
|
||||
},
|
||||
Math.max(0, input.timeoutMs),
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([input.promise, timeoutPromise]);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("terminal key helpers", () => {
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
@@ -59,7 +59,7 @@ describe("terminal key helpers", () => {
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
@@ -67,7 +67,7 @@ describe("terminal key helpers", () => {
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
@@ -75,17 +75,13 @@ describe("terminal key helpers", () => {
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects pending modifier state", () => {
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("maps onData bytes to terminal keys for modifier fallback", () => {
|
||||
@@ -103,7 +99,7 @@ describe("terminal key helpers", () => {
|
||||
resolvePendingModifierDataInput({
|
||||
data: "hello",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: true,
|
||||
@@ -115,7 +111,7 @@ describe("terminal key helpers", () => {
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "key",
|
||||
key: "c",
|
||||
@@ -128,7 +124,7 @@ describe("terminal key helpers", () => {
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: false,
|
||||
|
||||
@@ -4,14 +4,7 @@ export type PendingTerminalModifiers = {
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
const MODIFIER_DOM_KEYS = new Set([
|
||||
"Control",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Meta",
|
||||
"AltGraph",
|
||||
"OS",
|
||||
]);
|
||||
const MODIFIER_DOM_KEYS = new Set(["Control", "Shift", "Alt", "Meta", "AltGraph", "OS"]);
|
||||
|
||||
const DOM_KEY_ALIASES: Record<string, string> = {
|
||||
Esc: "Escape",
|
||||
@@ -63,12 +56,7 @@ export function normalizeDomTerminalKey(rawKey: string): string | null {
|
||||
}
|
||||
|
||||
const key = DOM_KEY_ALIASES[rawKey] ?? rawKey;
|
||||
if (
|
||||
key === "Unidentified" ||
|
||||
key === "Dead" ||
|
||||
key === "Compose" ||
|
||||
key === "Process"
|
||||
) {
|
||||
if (key === "Unidentified" || key === "Dead" || key === "Compose" || key === "Process") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,9 +78,7 @@ export function normalizeTerminalTransportKey(key: string): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
export function hasPendingTerminalModifiers(
|
||||
modifiers: PendingTerminalModifiers
|
||||
): boolean {
|
||||
export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers): boolean {
|
||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CreateTerminalResponse,
|
||||
ListTerminalsResponse,
|
||||
} from "@server/shared/messages";
|
||||
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
|
||||
|
||||
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
@@ -18,9 +15,7 @@ export function upsertTerminalListEntry(input: {
|
||||
terminal: CreatedTerminal;
|
||||
}): TerminalListEntry[] {
|
||||
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
|
||||
const existingIndex = input.terminals.findIndex(
|
||||
(terminal) => terminal.id === createdTerminal.id
|
||||
);
|
||||
const existingIndex = input.terminals.findIndex((terminal) => terminal.id === createdTerminal.id);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return [...input.terminals, createdTerminal];
|
||||
|
||||
@@ -79,9 +79,7 @@ export function appendTerminalOutputBuffer(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function readTerminalOutputBuffer(input: {
|
||||
buffer: TerminalOutputBuffer;
|
||||
}): string {
|
||||
export function readTerminalOutputBuffer(input: { buffer: TerminalOutputBuffer }): string {
|
||||
const { buffer } = input;
|
||||
if (buffer.totalChars <= 0) {
|
||||
return "";
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export class DaemonConnectionTestError extends Error {
|
||||
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string
|
||||
serverId?: string,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
@@ -99,44 +99,51 @@ export function connectAndProbe(
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const client = new DaemonClient(config);
|
||||
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
reject(
|
||||
new DaemonConnectionTestError("Connection timed out", {
|
||||
reason: "Connection timed out",
|
||||
lastError: client.lastError ?? null,
|
||||
})
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
void client.connect().then(() => {
|
||||
clearTimeout(timer);
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
reject(
|
||||
new DaemonConnectionTestError("Missing server info message", {
|
||||
reason: "Missing server info message",
|
||||
new DaemonConnectionTestError("Connection timed out", {
|
||||
reason: "Connection timed out",
|
||||
lastError: client.lastError ?? null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
client,
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
});
|
||||
}).catch((error) => {
|
||||
clearTimeout(timer);
|
||||
const reason = normalizeNonEmptyString(error instanceof Error ? error.message : String(error));
|
||||
const lastError = normalizeNonEmptyString(client.lastError);
|
||||
const message = pickBestReason(reason, lastError);
|
||||
void client.close().catch(() => undefined);
|
||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||
});
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
void client
|
||||
.connect()
|
||||
.then(() => {
|
||||
clearTimeout(timer);
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
void client.close().catch(() => undefined);
|
||||
reject(
|
||||
new DaemonConnectionTestError("Missing server info message", {
|
||||
reason: "Missing server info message",
|
||||
lastError: client.lastError ?? null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
client,
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
const reason = normalizeNonEmptyString(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
const lastError = normalizeNonEmptyString(client.lastError);
|
||||
const message = pickBestReason(reason, lastError);
|
||||
void client.close().catch(() => undefined);
|
||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface ProbeOptions {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,4 +32,3 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme {
|
||||
brightWhite: terminal.brightWhite,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "./tool-call-detail-state";
|
||||
import { hasMeaningfulToolCallDetail, isPendingToolCallDetail } from "./tool-call-detail-state";
|
||||
|
||||
describe("tool-call detail state", () => {
|
||||
it("treats empty unknown payloads as not meaningful", () => {
|
||||
@@ -39,7 +36,7 @@ describe("tool-call detail state", () => {
|
||||
status: "running",
|
||||
error: null,
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,7 +51,7 @@ describe("tool-call detail state", () => {
|
||||
status: "completed",
|
||||
error: null,
|
||||
}),
|
||||
false
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ function hasMeaningfulUnknownValue(value: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasMeaningfulToolCallDetail(
|
||||
detail: ToolCallDetail | undefined
|
||||
): boolean {
|
||||
export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
@@ -32,9 +30,7 @@ export function hasMeaningfulToolCallDetail(
|
||||
case "read":
|
||||
return Boolean(detail.filePath || detail.content);
|
||||
case "edit":
|
||||
return Boolean(
|
||||
detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString
|
||||
);
|
||||
return Boolean(detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString);
|
||||
case "write":
|
||||
return Boolean(detail.filePath || detail.content);
|
||||
case "search":
|
||||
@@ -43,7 +39,7 @@ export function hasMeaningfulToolCallDetail(
|
||||
detail.content ||
|
||||
(detail.filePaths && detail.filePaths.length > 0) ||
|
||||
(detail.webResults && detail.webResults.length > 0) ||
|
||||
(detail.annotations && detail.annotations.length > 0)
|
||||
(detail.annotations && detail.annotations.length > 0),
|
||||
);
|
||||
case "fetch":
|
||||
return Boolean(detail.url || detail.result || detail.codeText);
|
||||
@@ -51,18 +47,12 @@ export function hasMeaningfulToolCallDetail(
|
||||
return Boolean(detail.branchName || detail.worktreePath || detail.log);
|
||||
case "sub_agent":
|
||||
return Boolean(
|
||||
detail.subAgentType ||
|
||||
detail.description ||
|
||||
detail.log ||
|
||||
detail.actions.length > 0
|
||||
detail.subAgentType || detail.description || detail.log || detail.actions.length > 0,
|
||||
);
|
||||
case "plain_text":
|
||||
return Boolean(detail.label || detail.text);
|
||||
case "unknown":
|
||||
return (
|
||||
hasMeaningfulUnknownValue(detail.input) ||
|
||||
hasMeaningfulUnknownValue(detail.output)
|
||||
);
|
||||
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +61,6 @@ export function isPendingToolCallDetail(params: {
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown | null | undefined;
|
||||
}): boolean {
|
||||
const isRunning =
|
||||
params.status === "running" || params.status === "executing";
|
||||
const isRunning = params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ const TOOL_ICON_BY_NAME: Record<ToolCallIconName, ToolCallIconComponent> = {
|
||||
mic_vocal: MicVocal,
|
||||
};
|
||||
|
||||
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
|
||||
export function resolveToolCallIcon(
|
||||
toolName: string,
|
||||
detail?: ToolCallDetail,
|
||||
): ToolCallIconComponent {
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
|
||||
if (detail?.type === "plain_text" && detail.icon) {
|
||||
|
||||
@@ -48,7 +48,10 @@ function splitIntoWords(text: string): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
|
||||
function computeWordLevelDiff(
|
||||
oldLine: string,
|
||||
newLine: string,
|
||||
): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
|
||||
const oldWords = splitIntoWords(oldLine);
|
||||
const newWords = splitIntoWords(newLine);
|
||||
|
||||
@@ -138,9 +141,7 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
@@ -270,7 +271,7 @@ const ClaudeTodoWriteSchema = z.object({
|
||||
content: z.string(),
|
||||
status: TaskStatusSchema,
|
||||
activeForm: z.string().optional(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -279,17 +280,20 @@ const UpdatePlanSchema = z.object({
|
||||
z.object({
|
||||
step: z.string(),
|
||||
status: TaskStatusSchema.catch("pending"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function normalizeToolName(toolName: string): string {
|
||||
return toolName.trim().replace(/[.\s-]+/g, "_").toLowerCase();
|
||||
return toolName
|
||||
.trim()
|
||||
.replace(/[.\s-]+/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function extractTaskEntriesFromToolCall(
|
||||
toolName: string,
|
||||
input: unknown
|
||||
input: unknown,
|
||||
): TaskEntry[] | null {
|
||||
const normalized = normalizeToolName(toolName);
|
||||
|
||||
|
||||
@@ -48,4 +48,3 @@ export function focusWithRetries({
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,35 +23,23 @@ describe("buildWorkingDirectorySuggestions", () => {
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/planbook",
|
||||
]);
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
});
|
||||
|
||||
it("puts matching recommended items first when they also match query", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/planbook",
|
||||
"/Users/me/projects/playground",
|
||||
],
|
||||
serverPaths: ["/Users/me/projects/planbook", "/Users/me/projects/playground"],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/planbook",
|
||||
]);
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
});
|
||||
|
||||
it("treats '~' as an active query and includes server suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||
serverPaths: [
|
||||
"/Users/me/documents",
|
||||
"/Users/me/projects",
|
||||
],
|
||||
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
|
||||
query: "~",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface BuildWorkingDirectorySuggestionsInput {
|
||||
}
|
||||
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput
|
||||
input: BuildWorkingDirectorySuggestionsInput,
|
||||
): string[] {
|
||||
const rawQuery = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "./working-indicator";
|
||||
import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_OFFSETS } from "./working-indicator";
|
||||
|
||||
describe("getWorkingIndicatorDotStrength", () => {
|
||||
it("returns a stable triangular pulse over one cycle", () => {
|
||||
@@ -16,7 +13,7 @@ describe("getWorkingIndicatorDotStrength", () => {
|
||||
|
||||
it("keeps the dots phase-shifted instead of identical", () => {
|
||||
const strengths = WORKING_INDICATOR_OFFSETS.map((offset) =>
|
||||
getWorkingIndicatorDotStrength(0, offset)
|
||||
getWorkingIndicatorDotStrength(0, offset),
|
||||
);
|
||||
|
||||
expect(strengths).toEqual([0, 0.26666666666666666, 0.5333333333333333]);
|
||||
@@ -24,10 +21,10 @@ describe("getWorkingIndicatorDotStrength", () => {
|
||||
|
||||
it("wraps progress cleanly across loop boundaries", () => {
|
||||
expect(getWorkingIndicatorDotStrength(1.1, 0)).toBeCloseTo(
|
||||
getWorkingIndicatorDotStrength(0.1, 0)
|
||||
getWorkingIndicatorDotStrength(0.1, 0),
|
||||
);
|
||||
expect(getWorkingIndicatorDotStrength(-0.1, 0)).toBeCloseTo(
|
||||
getWorkingIndicatorDotStrength(0.9, 0)
|
||||
getWorkingIndicatorDotStrength(0.9, 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,7 @@ function normalizePhase(value: number): number {
|
||||
return wrapped < 0 ? wrapped + 1 : wrapped;
|
||||
}
|
||||
|
||||
export function getWorkingIndicatorDotStrength(
|
||||
progress: number,
|
||||
offset: number
|
||||
): number {
|
||||
export function getWorkingIndicatorDotStrength(progress: number, offset: number): number {
|
||||
"worklet";
|
||||
const phase = normalizePhase(progress + offset);
|
||||
if (phase <= 0.5) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function workspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
@@ -33,7 +33,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
resolveWorkspaceArchiveRedirectWorkspaceId({
|
||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||
workspaces,
|
||||
})
|
||||
}),
|
||||
).toBe("/repo");
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
resolveWorkspaceArchiveRedirectWorkspaceId({
|
||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||
workspaces,
|
||||
})
|
||||
}),
|
||||
).toBe("/repo");
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
serverId: "server-1",
|
||||
archivedWorkspaceId: "/notes",
|
||||
workspaces,
|
||||
})
|
||||
}),
|
||||
).toBe("/h/server-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,12 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
|
||||
}
|
||||
|
||||
const sameProjectWorkspaces = workspaces.filter(
|
||||
(workspace) => workspace.projectId === archivedWorkspace.projectId
|
||||
(workspace) => workspace.projectId === archivedWorkspace.projectId,
|
||||
);
|
||||
const rootCheckoutWorkspace =
|
||||
sameProjectWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.workspaceKind === "local_checkout" &&
|
||||
workspace.id !== archivedWorkspace.id
|
||||
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
|
||||
) ?? null;
|
||||
if (rootCheckoutWorkspace) {
|
||||
return rootCheckoutWorkspace.id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export function normalizeWorkspaceTabTarget(
|
||||
value: WorkspaceTabTarget | null | undefined
|
||||
value: WorkspaceTabTarget | null | undefined,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
@@ -27,7 +27,7 @@ export function normalizeWorkspaceTabTarget(
|
||||
|
||||
export function workspaceTabTargetsEqual(
|
||||
left: WorkspaceTabTarget,
|
||||
right: WorkspaceTabTarget
|
||||
right: WorkspaceTabTarget,
|
||||
): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user