mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917894f58b |
No files matched your search
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
|
||||
] as MenuItem[])
|
||||
: ([
|
||||
{ kind: "separator" },
|
||||
openInMenu(slot.model.path),
|
||||
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
|
||||
{ kind: "separator" },
|
||||
localHistoryMenu(slot.model.path),
|
||||
gitMenu(slot.model.path),
|
||||
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
|
||||
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
|
||||
function openInMenu(path: string): MenuItem {
|
||||
function openInMenu(path: string, line?: number): MenuItem {
|
||||
return {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
@@ -1971,7 +1971,7 @@ function openInMenu(path: string): MenuItem {
|
||||
: []),
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
...menuGroupItems("openIn", { paths: [path], line }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileWebUrl, remoteHosting } from "../model";
|
||||
|
||||
describe("remoteHosting", () => {
|
||||
it.each([
|
||||
["[email protected]:o/r.git", "GitHub", "github"],
|
||||
["https://github.example.com/o/r", "GitHub", "github"],
|
||||
["https://gitee.com/o/r.git", "Gitee", "github"],
|
||||
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
|
||||
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
|
||||
["[email protected]:group/r.git", "git.example.com", "gitlab"],
|
||||
])("%s → %s", (url, name, kind) => {
|
||||
expect(remoteHosting(url)).toEqual({ name, kind });
|
||||
});
|
||||
|
||||
it("has none for a local remote", () => {
|
||||
expect(remoteHosting("/srv/git/r.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileWebUrl", () => {
|
||||
it("opens a file at a line on GitHub", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "src/a b.ts", { line: 9 })).toBe(
|
||||
"https://github.com/o/r/blob/main/src/a%20b.ts#L10",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a branch's slashes and opens directories as trees", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "feature/x", "src", { dir: true, line: 3 })).toBe(
|
||||
"https://github.com/o/r/tree/feature/x/src",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses GitLab's /-/ routes, including subgroups", () => {
|
||||
expect(fileWebUrl("ssh://[email protected]:2222/group/sub/r.git", "abc123", "docs/中文.md", { line: 0 })).toBe(
|
||||
"https://gitlab.example.com/group/sub/r/-/blob/abc123/docs/%E4%B8%AD%E6%96%87.md#L1",
|
||||
);
|
||||
expect(fileWebUrl("[email protected]:g/r.git", "main", "", { dir: true })).toBe("https://gitlab.com/g/r/-/tree/main");
|
||||
});
|
||||
|
||||
it("uses Bitbucket's /src/ routes", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "a.py", { line: 4 })).toBe(
|
||||
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
registerTitleItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerVcsActions } from "./actions";
|
||||
@@ -50,6 +50,7 @@ import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore"
|
||||
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
|
||||
import { installRootSync } from "./rootSync";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openOnRemoteActions, registerOpenOnRemote } from "./openOnRemote";
|
||||
|
||||
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
|
||||
function trackMergeRebaseItem(): () => void {
|
||||
@@ -231,6 +232,8 @@ export function activate(): () => void {
|
||||
(signOff) => useCommit.setState({ signOff }),
|
||||
),
|
||||
registerVcsActions(),
|
||||
registerActions(openOnRemoteActions),
|
||||
registerOpenOnRemote(),
|
||||
registerHistoryActions(),
|
||||
registerLogPaneActions(),
|
||||
activateSettingsPages(),
|
||||
|
||||
@@ -165,7 +165,51 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
|
||||
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */
|
||||
/** How a Git hosting service lays out its file pages. */
|
||||
export type HostingKind = "github" | "gitlab" | "bitbucket";
|
||||
|
||||
/**
|
||||
* The hosting service of a remote, by its host: GitHub (and GitHub Enterprise, Gitee, which share
|
||||
* its URLs), Bitbucket, and GitLab for gitlab.* and any other host, as self-managed GitLab is the
|
||||
* usual company server. Null for remotes with no web page (local paths).
|
||||
*/
|
||||
export function remoteHosting(url: string): { name: string; kind: HostingKind } | null {
|
||||
const base = remoteWebUrl(url);
|
||||
if (!base) return null;
|
||||
const host = new URL(base).hostname.toLowerCase();
|
||||
if (host === "github.com" || host.includes("github")) return { name: "GitHub", kind: "github" };
|
||||
if (host === "gitee.com") return { name: "Gitee", kind: "github" };
|
||||
if (host === "bitbucket.org") return { name: "Bitbucket", kind: "bitbucket" };
|
||||
if (host.includes("gitlab")) return { name: "GitLab", kind: "gitlab" };
|
||||
return { name: host, kind: "gitlab" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page of a file or directory at `revision` on the remote's web UI, with the 0-based `line`
|
||||
* selected. `relative` is the `/`-separated path inside the repository ("" for its root).
|
||||
*/
|
||||
export function fileWebUrl(
|
||||
remoteUrl: string,
|
||||
revision: string,
|
||||
relative: string,
|
||||
opts: { dir?: boolean; line?: number } = {},
|
||||
): string | null {
|
||||
const base = remoteWebUrl(remoteUrl);
|
||||
const hosting = remoteHosting(remoteUrl);
|
||||
if (!base || !hosting) return null;
|
||||
const path = [...revision.split("/"), ...relative.split("/")].filter(Boolean).map(encodeURIComponent).join("/");
|
||||
const line = opts.line !== undefined && !opts.dir ? opts.line + 1 : null;
|
||||
switch (hosting.kind) {
|
||||
case "github":
|
||||
return `${base}/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "gitlab":
|
||||
return `${base}/-/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "bitbucket":
|
||||
return `${base}/src/${path}${line ? `#lines-${line}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */ /** "N file(s)", worded for the locale. */
|
||||
export function fileCount(n: number): string {
|
||||
return n === 1 ? tx`${n} file` : tx`${n} files`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Open In › GitHub / GitLab / Bitbucket: the file (at the caret line) or directory on the web UI
|
||||
* of its repository's remote, at the current branch's upstream (else the HEAD commit).
|
||||
*/
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { notifications, registerMenuGroup } from "@/workbench/contributions";
|
||||
import { openUrl } from "./issueLinks";
|
||||
import { fileWebUrl, remoteHosting, rootOf } from "./model";
|
||||
import { useVcs } from "./store";
|
||||
|
||||
interface Target {
|
||||
path: string;
|
||||
dir?: boolean;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** Root → its remote's URL (null: none), read when the repositories change. */
|
||||
const remoteUrls = new Map<string, string | null>();
|
||||
|
||||
/** The remote a root's files open on: the selected repository's upstream remote, else `origin`. */
|
||||
function remoteName(root: string): string {
|
||||
const { repo, branch } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
return upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
|
||||
}
|
||||
|
||||
async function readRemoteUrl(root: string): Promise<string | null> {
|
||||
const url = await call(commands.vcsConfigGet(root, `remote.${remoteName(root)}.url`)).catch(() => null);
|
||||
remoteUrls.set(root, url || null);
|
||||
return url || null;
|
||||
}
|
||||
|
||||
function revision(root: string): string | null {
|
||||
const { repo, branch, heads } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
if (upstream) return upstream.slice(upstream.indexOf("/") + 1);
|
||||
return heads[root]?.headCommit ?? null;
|
||||
}
|
||||
|
||||
function targetArg(arg: unknown): Target | null {
|
||||
if (typeof arg === "string") return { path: arg };
|
||||
if (arg && typeof arg === "object" && typeof (arg as Target).path === "string") return arg as Target;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openOnRemote(target: Target) {
|
||||
const root = rootOf(target.path, useVcs.getState().roots);
|
||||
const url = root ? (remoteUrls.get(root) ?? (await readRemoteUrl(root))) : null;
|
||||
const rev = root ? revision(root) : null;
|
||||
const page =
|
||||
root && url && rev ? fileWebUrl(url, rev, target.path.slice(root.length).replace(/^\/+/, ""), target) : null;
|
||||
if (page) await openUrl(page);
|
||||
else notifications().notify({ group: "vcs", level: "warning", title: "This file has no page on a Git remote" });
|
||||
}
|
||||
|
||||
export const openOnRemoteActions: Action[] = [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
title: "Open on Git Remote",
|
||||
description: "Opens the file or directory on the web page of its repository's remote.",
|
||||
category: "Git",
|
||||
icon: "external-link",
|
||||
when: "vcsAvailable",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = targetArg(arg);
|
||||
if (target) void openOnRemote(target);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Open In › <hosting>, for files and directories inside a repository with a web remote. */
|
||||
export function registerOpenOnRemote(): () => void {
|
||||
const refresh = (roots: readonly string[]) => {
|
||||
for (const root of [...remoteUrls.keys()]) if (!roots.includes(root)) remoteUrls.delete(root);
|
||||
for (const root of roots) void readRemoteUrl(root);
|
||||
};
|
||||
refresh(useVcs.getState().roots);
|
||||
const unsubscribe = useVcs.subscribe((s, prev) => {
|
||||
if (s.roots !== prev.roots || s.branch?.upstream !== prev.branch?.upstream) refresh(s.roots);
|
||||
});
|
||||
const unregister = registerMenuGroup({
|
||||
id: "vcs.openOnRemote",
|
||||
menus: ["openIn"],
|
||||
items: (_menu, { paths, dirs, line }) => {
|
||||
if (paths.length !== 1) return [];
|
||||
const path = paths[0]!;
|
||||
const root = rootOf(path, useVcs.getState().roots);
|
||||
const url = root ? remoteUrls.get(root) : null;
|
||||
const hosting = url ? remoteHosting(url) : null;
|
||||
if (!hosting) return [];
|
||||
const target: Target = { path, dir: dirs?.[0] ?? false, line };
|
||||
return [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
// A service or host name, the same in every language.
|
||||
label: hosting.name,
|
||||
verbatim: true,
|
||||
icon: hosting.kind === "github" ? "github" : "external-link",
|
||||
run: () => void openOnRemote(target),
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unregister();
|
||||
remoteUrls.clear();
|
||||
};
|
||||
}
|
||||
@@ -1456,5 +1456,8 @@
|
||||
"Dependencies": "Dependencies",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.",
|
||||
"No credential store": "No credential store",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you."
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you.",
|
||||
"Open on Git Remote": "Open on Git Remote",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "Opens the file or directory on the web page of its repository's remote.",
|
||||
"This file has no page on a Git remote": "This file has no page on a Git remote"
|
||||
}
|
||||
@@ -7854,5 +7854,8 @@
|
||||
"All Interpreters": "所有解释器",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "配置项目解释器及其软件包、IDE 已知的所有解释器,以及 Ruff。",
|
||||
"No credential store": "没有凭据存储",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。"
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。",
|
||||
"Open on Git Remote": "在 Git 远程上打开",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "在仓库远程的网页上打开该文件或目录。",
|
||||
"This file has no page on a Git remote": "该文件在 Git 远程上没有对应页面"
|
||||
}
|
||||
Reference in new issue
Block a user