From ea4fe703b2a8a2c6f81b5072532dc20b07c6d99c Mon Sep 17 00:00:00 2001 From: Ereli Date: Thu, 12 Mar 2026 14:36:01 -0400 Subject: [PATCH] feat: add hyperlink support to GitBranch and GitRootDir widgets - GitBranch: toggle (l)ink wraps branch name in OSC 8 link to github.com/owner/repo/tree/; only activates for GitHub remotes, falls back to plain text otherwise - GitRootDir: toggle (l)ink wraps project root name in OSC 8 cursor://file/ link to open the directory in Cursor - Add shared src/utils/hyperlink.ts with renderOsc8Link and parseGitHubBaseUrl helpers Co-Authored-By: Claude Sonnet 4.6 --- src/utils/hyperlink.ts | 26 ++++++++++++++++++++ src/widgets/GitBranch.ts | 51 ++++++++++++++++++++++++++++++++++----- src/widgets/GitRootDir.ts | 42 +++++++++++++++++++++++++++----- 3 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 src/utils/hyperlink.ts diff --git a/src/utils/hyperlink.ts b/src/utils/hyperlink.ts new file mode 100644 index 00000000..91b41cbb --- /dev/null +++ b/src/utils/hyperlink.ts @@ -0,0 +1,26 @@ +export function renderOsc8Link(url: string, text: string): string { + return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; +} + +/** + * Converts a git remote URL to a GitHub HTTPS base URL. + * Handles both SSH (git@github.com:owner/repo.git) and HTTPS formats. + * Returns null if the remote is not a GitHub URL. + */ +export function parseGitHubBaseUrl(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + + // SSH format: git@github.com:owner/repo.git + const sshMatch = /^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/.exec(trimmed); + if (sshMatch?.[1]) { + return `https://github.com/${sshMatch[1]}`; + } + + // HTTPS format: https://github.com/owner/repo.git + const httpsMatch = /^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/.exec(trimmed); + if (httpsMatch?.[1]) { + return `https://github.com/${httpsMatch[1]}`; + } + + return null; +} \ No newline at end of file diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 42392890..5a65ba20 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -10,13 +10,25 @@ import { isInsideGitWorkTree, runGit } from '../utils/git'; +import { + parseGitHubBaseUrl, + renderOsc8Link +} from '../utils/hyperlink'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, getHideNoGitModifierText, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const LINK_KEY = 'linkToGitHub'; +const TOGGLE_LINK_ACTION = 'toggle-link'; export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -24,21 +36,34 @@ export class GitBranchWidget implements Widget { getDisplayName(): string { return 'Git Branch'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const isLink = isMetadataFlagEnabled(item, LINK_KEY); + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + if (isLink) + modifiers.push('GitHub link'); return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText(modifiers) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_LINK_ACTION) { + return toggleMetadataFlag(item, LINK_KEY); + } return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; const hideNoGit = isHideNoGitEnabled(item); + const isLink = isMetadataFlagEnabled(item, LINK_KEY); if (context.isPreview) { - return item.rawValue ? 'main' : '⎇ main'; + const text = item.rawValue ? 'main' : '⎇ main'; + return isLink ? renderOsc8Link('https://github.com/owner/repo/tree/main', text) : text; } if (!isInsideGitWorkTree(context)) { @@ -46,10 +71,21 @@ export class GitBranchWidget implements Widget { } const branch = this.getGitBranch(context); - if (branch) - return item.rawValue ? branch : `⎇ ${branch}`; + if (!branch) { + return hideNoGit ? null : '⎇ no git'; + } + + const displayText = item.rawValue ? branch : `⎇ ${branch}`; + + if (isLink) { + const remoteUrl = runGit('remote get-url origin', context); + const baseUrl = remoteUrl ? parseGitHubBaseUrl(remoteUrl) : null; + if (baseUrl) { + return renderOsc8Link(`${baseUrl}/tree/${branch}`, displayText); + } + } - return hideNoGit ? null : '⎇ no git'; + return displayText; } private getGitBranch(context: RenderContext): string | null { @@ -57,7 +93,10 @@ export class GitBranchWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + { key: 'l', label: '(l)ink to GitHub', action: TOGGLE_LINK_ACTION } + ]; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts index 56bce642..70bd393b 100644 --- a/src/widgets/GitRootDir.ts +++ b/src/widgets/GitRootDir.ts @@ -10,13 +10,22 @@ import { isInsideGitWorkTree, runGit } from '../utils/git'; +import { renderOsc8Link } from '../utils/hyperlink'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, getHideNoGitModifierText, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const LINK_KEY = 'linkToCursor'; +const TOGGLE_LINK_ACTION = 'toggle-link'; export class GitRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -24,21 +33,33 @@ export class GitRootDirWidget implements Widget { getDisplayName(): string { return 'Git Root Dir'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const isLink = isMetadataFlagEnabled(item, LINK_KEY); + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + if (isLink) + modifiers.push('Cursor link'); return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText(modifiers) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_LINK_ACTION) { + return toggleMetadataFlag(item, LINK_KEY); + } return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoGit = isHideNoGitEnabled(item); + const isLink = isMetadataFlagEnabled(item, LINK_KEY); if (context.isPreview) { - return 'my-repo'; + const name = 'my-repo'; + return isLink ? renderOsc8Link('cursor://file/Users/example/my-repo', name) : name; } if (!isInsideGitWorkTree(context)) { @@ -46,11 +67,17 @@ export class GitRootDirWidget implements Widget { } const rootDir = this.getGitRootDir(context); - if (rootDir) { - return this.getRootDirName(rootDir); + if (!rootDir) { + return hideNoGit ? null : 'no git'; + } + + const name = this.getRootDirName(rootDir); + + if (isLink) { + return renderOsc8Link(`cursor://file${rootDir}`, name); } - return hideNoGit ? null : 'no git'; + return name; } private getGitRootDir(context: RenderContext): string | null { @@ -66,7 +93,10 @@ export class GitRootDirWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + { key: 'l', label: '(l)ink to Cursor', action: TOGGLE_LINK_ACTION } + ]; } supportsRawValue(): boolean { return false; }