Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ import * as Option from "effect/Option";
import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted(
() => ({
buildFromTemplateMock: vi.fn(),
createFromNamedImageMock: vi.fn(),
setApplicationMenuMock: vi.fn(),
}),
);
const {
buildFromTemplateMock,
createFromBufferMock,
createFromNamedImageMock,
setApplicationMenuMock,
} = vi.hoisted(() => ({
buildFromTemplateMock: vi.fn(),
createFromBufferMock: vi.fn(),
createFromNamedImageMock: vi.fn(),
setApplicationMenuMock: vi.fn(),
}));

vi.mock("electron", () => ({
Menu: {
buildFromTemplate: buildFromTemplateMock,
setApplicationMenu: setApplicationMenuMock,
},
nativeImage: {
createFromBuffer: createFromBufferMock,
createFromNamedImage: createFromNamedImageMock,
},
}));
Expand All @@ -30,6 +35,9 @@ import * as ElectronMenu from "./ElectronMenu.ts";
const TestLayer = ElectronMenu.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
);
const DarwinTestLayer = ElectronMenu.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
);

const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
({
Expand All @@ -40,6 +48,7 @@ const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
describe("ElectronMenu", () => {
beforeEach(() => {
buildFromTemplateMock.mockReset();
createFromBufferMock.mockReset();
createFromNamedImageMock.mockReset();
setApplicationMenuMock.mockReset();
});
Expand Down Expand Up @@ -122,6 +131,55 @@ describe("ElectronMenu", () => {
}).pipe(Effect.provide(TestLayer)),
);

it.effect("appends renderer actions to the native edit menu", () =>
Effect.gen(function* () {
const gitHubIcon = {
isEmpty: () => false,
setTemplateImage: vi.fn(),
};
const nativeGitHubIcon = gitHubIcon as unknown as Electron.NativeImage;
createFromBufferMock.mockReturnValue(nativeGitHubIcon);
buildFromTemplateMock.mockImplementation(() => ({
popup: (options: Electron.PopupOptions) => options.callback?.(),
}));

const electronMenu = yield* ElectronMenu.ElectronMenu;
yield* electronMenu.showContextMenu({
window: makeWindow(),
items: [
{
id: "copy-github-link",
label: "Copy GitHub link",
icon: "github",
accelerator: "CmdOrCtrl+Shift+C",
},
],
position: Option.none(),
editFlags: {
canCut: false,
canCopy: true,
canPaste: false,
canSelectAll: true,
},
});

const template = buildFromTemplateMock.mock.calls[0]?.[0] as
| Electron.MenuItemConstructorOptions[]
| undefined;
assert.deepEqual(
template?.map((item) => item.type ?? item.role ?? item.label),
["cut", "copy", "paste", "selectAll", "Copy GitHub link"],
);
assert.deepEqual(
template?.slice(0, 4).map((item) => item.enabled),
[false, true, false, true],
);
assert.equal(template?.at(-1)?.accelerator, "CmdOrCtrl+Shift+C");
assert.strictEqual(template?.at(-1)?.icon, nativeGitHubIcon);
assert.deepEqual(createFromBufferMock.mock.calls[0]?.[1], { scaleFactor: 2 });
}).pipe(Effect.provide(DarwinTestLayer)),
);

it.effect("keeps a preceding non-destructive action in the destructive section", () =>
Effect.gen(function* () {
buildFromTemplateMock.mockImplementation(() => ({
Expand Down
50 changes: 47 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ContextMenuItem } from "@t3tools/contracts";
import type { ContextMenuEditFlags, ContextMenuItem } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand All @@ -8,6 +8,9 @@ import * as Schema from "effect/Schema";

import * as Electron from "electron";

const GITHUB_MARK_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAGKADAAQAAAABAAAAGAAAAADB/VeXAAACD0lEQVRIDZWVP2tUQRRHX1TEgDbBFTSS1ToI6gewEGsVISqInQRsghLE2m9gZSN2/vsAKhhBthCxNgg2FqYIERWJlibqOTGz3FzX3TcXznv3ztz7m9mZebNjzXDr0n0RTsI0dED7DO/gJTyGj1BlU2Q/gnX4PQJzzLWmlZ0m6weMEs791lg71Obo/QW5uG1srRoD7QytRXwN/ye0FTa35Kvxzy/p0hiX5TbxLnCD34MDfYJFeAvLYNsHuAzjcAvKhNSagr65SaXT92y/p2l24u8PcXH34Shc7AJO1FBzww7xzKdl/m9X1fMK2XEANbvbeLgMvqNVn2uKl6IAftFuFgjiyK9SYk34NGk9dxS/0GjPYlDpO0C0aQfYG1vwv6S4JvyakjsO4JmPNhGDSj/XrjlAnvHxStGYfiwGRTtvjB9JJyW2CfeQ5C0bD8wTf0EPou0muAvbY+MIf4z+O5D3s2fdJJR75DX+EjiLN3AKnMT/TOET0IM4c301D8CGPeBpo8vlLF5sxrZ9gxuQ7SoNnposXOL7scBf8X0z+R5v98CLzWQ/+aOQ7TANRSy/1erPvhSex/GqVfAIuAfxb5Jwi7k85mZxNWa2ZIbgGr4JK3ATvNcvgWKDLA9g7fVBibHN0VchzmxHTAi+giXPZfHKbmWun5vkSVDA/4RB5gDmPAT3sdosOjek6ix9B4f0N38AdIrKZCnytp4AAAAASUVORK5CYII=";

export interface ElectronMenuPosition {
readonly x: number;
readonly y: number;
Expand All @@ -17,6 +20,7 @@ export interface ElectronMenuContextInput {
readonly window: Electron.BrowserWindow;
readonly items: readonly ContextMenuItem[];
readonly position: Option.Option<ElectronMenuPosition>;
readonly editFlags?: ContextMenuEditFlags;
}

export interface ElectronMenuTemplateInput {
Expand Down Expand Up @@ -79,6 +83,8 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
destructive: sourceItem.destructive === true,
disabled: sourceItem.disabled === true,
...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
...(sourceItem.icon === undefined ? {} : { icon: sourceItem.icon }),
...(sourceItem.accelerator === undefined ? {} : { accelerator: sourceItem.accelerator }),
};

if (sourceItem.children) {
Expand All @@ -95,6 +101,17 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

export function buildEditContextMenuTemplate(
flags: ContextMenuEditFlags,
): Electron.MenuItemConstructorOptions[] {
return [
{ role: "cut", enabled: flags.canCut },
{ role: "copy", enabled: flags.canCopy },
{ role: "paste", enabled: flags.canPaste },
{ role: "selectAll", enabled: flags.canSelectAll },
];
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
Expand All @@ -113,6 +130,7 @@ const normalizePosition = (
export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
let gitHubMenuIconCache: Option.Option<Electron.NativeImage> | undefined;

const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
if (platform !== "darwin") {
Expand All @@ -136,6 +154,24 @@ export const make = Effect.gen(function* () {
return destructiveMenuIconCache;
};

const getGitHubMenuIcon = (): Option.Option<Electron.NativeImage> => {
if (platform !== "darwin") return Option.none();
if (gitHubMenuIconCache !== undefined) return gitHubMenuIconCache;

try {
const icon = Electron.nativeImage.createFromBuffer(
Buffer.from(GITHUB_MARK_PNG_BASE64, "base64"),
{ scaleFactor: 2 },
);
icon.setTemplateImage(true);
gitHubMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
} catch {
gitHubMenuIconCache = Option.none();
}

return gitHubMenuIconCache;
};

const buildTemplate = (
entries: readonly ContextMenuItem[],
complete: (selectedItemId: Option.Option<string>) => void,
Expand Down Expand Up @@ -166,12 +202,17 @@ export const make = Effect.gen(function* () {
const itemOption: Electron.MenuItemConstructorOptions = {
label: item.label,
enabled: !item.disabled,
...(item.accelerator === undefined ? {} : { accelerator: item.accelerator }),
};
if (item.children && item.children.length > 0) {
itemOption.submenu = buildTemplate(item.children, complete);
} else {
itemOption.click = () => complete(Option.some(item.id));
}
if (item.icon === "github") {
const gitHubIcon = getGitHubMenuIcon();
if (Option.isSome(gitHubIcon)) itemOption.icon = gitHubIcon.value;
}
if (item.destructive && (!item.children || item.children.length === 0)) {
const destructiveIcon = getDestructiveMenuIcon();
if (Option.isSome(destructiveIcon)) {
Expand Down Expand Up @@ -220,7 +261,7 @@ export const make = Effect.gen(function* () {
showContextMenu: (input) =>
Effect.callback<Option.Option<string>>((resume) => {
const normalizedItems = normalizeContextMenuItems(input.items);
if (normalizedItems.length === 0) {
if (normalizedItems.length === 0 && input.editFlags === undefined) {
resume(Effect.succeed(Option.none()));
return;
}
Expand All @@ -235,7 +276,10 @@ export const make = Effect.gen(function* () {
};

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const template =
input.editFlags === undefined ? [] : buildEditContextMenuTemplate(input.editFlags);
template.push(...buildTemplate(normalizedItems, complete));
const menu = Electron.Menu.buildFromTemplate(template);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ContextMenuEditFlagsSchema,
ContextMenuItemSchema,
DesktopAppBrandingSchema,
DesktopEnvironmentBootstrapSchema,
Expand Down Expand Up @@ -49,6 +50,7 @@ const ContextMenuPosition = Schema.Struct({
const ContextMenuInput = Schema.Struct({
items: Schema.Array(ContextMenuItemSchema),
position: Schema.optionalKey(ContextMenuPosition),
editFlags: Schema.optionalKey(ContextMenuEditFlagsSchema),
});

function toWebSocketBaseUrl(httpBaseUrl: URL): string {
Expand Down Expand Up @@ -283,6 +285,7 @@ export const showContextMenu = DesktopIpc.makeIpcMethod({
window: window.value,
items: input.items,
position: Option.fromNullishOr(input.position),
...(input.editFlags === undefined ? {} : { editFlags: input.editFlags }),
});
return Option.getOrNull(selectedItemId);
}),
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) =>
showContextMenu: (items, position, editFlags) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
items,
...(position === undefined ? {} : { position }),
...(editFlags === undefined ? {} : { editFlags }),
}),
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
Expand Down
7 changes: 1 addition & 6 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,12 +530,7 @@ export const make = Effect.gen(function* () {
menuTemplate.push({ type: "separator" });
}

menuTemplate.push(
{ role: "cut", enabled: params.editFlags.canCut },
{ role: "copy", enabled: params.editFlags.canCopy },
{ role: "paste", enabled: params.editFlags.canPaste },
{ role: "selectAll", enabled: params.editFlags.canSelectAll },
);
menuTemplate.push(...ElectronMenu.buildEditContextMenuTemplate(params.editFlags));

void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
});
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(resolverLayer));
});

it.effect("recognizes fetch remotes annotated with a partial clone filter", () => {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-partial-clone-test-",
});

yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "https://github.com/T3Tools/t3code.git"]);
yield* git(cwd, ["config", "remote.origin.promisor", "true"]);
yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]);
const remotes = yield* git(cwd, ["remote", "-v"]);

expect(remotes.stdout).toContain("(fetch) [blob:none]");

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);

expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(identity?.provider).toBe("github");
expect(identity?.locator.remoteName).toBe("origin");
}).pipe(Effect.provide(RepositoryIdentityResolver.layer));
});

it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\r\n]+\])?$/.exec(trimmed);
if (!match) continue;
const [, remoteName = "", remoteUrl = "", direction = ""] = match;
if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
Expand Down
48 changes: 48 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,54 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
});

describe("refName operations", () => {
it.effect("filters exact ref names before pagination", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-vcs-driver-remote-");
const { initialBranch } = yield* initRepoWithCommit(cwd);
yield* git(remote, ["init", "--bare"]);
yield* git(cwd, ["remote", "add", "origin", remote]);

const targetBranch = "feature/github-links";
const newerSimilarBranch = `${targetBranch}-next`;
yield* git(cwd, ["branch", targetBranch]);
yield* git(cwd, ["checkout", "-b", newerSimilarBranch]);
yield* writeTextFile(cwd, "newer.txt", "newer\n");
yield* git(cwd, ["add", "newer.txt"]);
yield* git(cwd, ["commit", "-m", "newer similar branch"], {
GIT_AUTHOR_DATE: "2030-01-02T00:00:00Z",
GIT_COMMITTER_DATE: "2030-01-02T00:00:00Z",
});
yield* git(cwd, ["push", "origin", initialBranch, targetBranch, newerSimilarBranch]);

const driver = yield* GitVcsDriver.GitVcsDriver;
const exactRefName = `origin/${targetBranch}`;
const fuzzy = yield* driver.listRefs({
cwd,
query: exactRefName,
includeMatchingRemoteRefs: true,
refKind: "remote",
limit: 1,
refresh: true,
});
assert.equal(fuzzy.refs[0]?.name, `origin/${newerSimilarBranch}`);

const exact = yield* driver.listRefs({
cwd,
exactName: exactRefName,
includeMatchingRemoteRefs: true,
refKind: "remote",
limit: 1,
});
assert.deepEqual(
exact.refs.map((ref) => ref.name),
[exactRefName],
);
assert.equal(exact.totalCount, 1);
assert.equal(exact.nextCursor, null);
}),
);

it.effect("optionally includes remote refs that match local branches", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2814,8 +2814,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
: input.refKind === "remote"
? allBranches.filter((ref) => ref.isRemote)
: allBranches;
const branchesMatchingExactName =
input.exactName === undefined
? branchesForKind
: branchesForKind.filter((ref) => ref.name === input.exactName);
const refs = paginateBranches({
refs: filterBranchesForListQuery(branchesForKind, input.query),
refs: filterBranchesForListQuery(branchesMatchingExactName, input.query),
cursor: input.cursor,
limit: input.limit,
});
Expand Down
Loading
Loading