Skip to content

Commit af26990

Browse files
authored
Merge pull request #83 from grimmerk/fix/menubar-shortcut-sync
fix: menubar shortcuts + PR links + active IDE dots
2 parents 48e078d + 71c30d2 commit af26990

11 files changed

Lines changed: 201 additions & 27 deletions

.github/workflows/notarize.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,20 @@ jobs:
7676
- name: Extract release notes from CHANGELOG.md
7777
id: changelog
7878
if: ${{ inputs.create_release && inputs.release_notes == '' }}
79+
env:
80+
GH_TOKEN: ${{ github.token }}
7981
run: |
8082
VERSION="${{ steps.version.outputs.version }}"
81-
# Extract section for this version from CHANGELOG.md
82-
# Note: can't use awk range pattern /start/,/end/ because the start line
83-
# "## 1.0.38" also matches the end pattern /^## [0-9]/ (version starts with digit),
84-
# causing awk to treat the range as a single line.
85-
NOTES=$(awk -v ver="## ${VERSION}" '$0 == ver {found=1; next} found && /^## / {exit} found' CHANGELOG.md | sed '/^$/d')
83+
# Find the last published release version to aggregate all unreleased entries
84+
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' 2>/dev/null || echo "")
85+
LAST_VER=$(echo "$LAST_TAG" | sed 's/^v//')
86+
# Extract all changelog sections from current version down to (but not including) last release
87+
if [ -n "$LAST_VER" ] && [ "$LAST_VER" != "$VERSION" ]; then
88+
NOTES=$(awk -v cur="## ${VERSION}" -v prev="## ${LAST_VER}" \
89+
'$0 == cur {found=1} found && $0 == prev {exit} found' CHANGELOG.md | sed '/^$/d')
90+
else
91+
NOTES=$(awk -v ver="## ${VERSION}" '$0 == ver {found=1; next} found && /^## / {exit} found' CHANGELOG.md | sed '/^$/d')
92+
fi
8693
if [ -z "$NOTES" ]; then
8794
NOTES="Non-App Store notarized build v${VERSION}"
8895
fi

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 1.0.57
4+
5+
- Fix: menubar Keyboard Shortcuts submenu now reflects custom shortcuts
6+
- Fix: GitHub release notes now aggregate all unreleased changelog entries
7+
- Feat: PR link badge in session list items (clickable, opens browser)
8+
- Feat: purple dot on projects currently open in VS Code/Cursor
9+
- Pin axios to 1.14.0 (avoid compromised 1.14.1)
10+
311
## 1.0.56
412

513
- Feat: embedded Terminal tab (xterm.js + node-pty)

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ Trigger `⌃+⌘+C` shortcut to launch pure AI chat mode, just like the Claude o
114114
- `db:migrate` will also automatically do this part, `yarn install` will also include generated types in node_modules/.prisma/index.d.ts)
115115
- `yarn start`
116116

117+
> **Git worktree note:** Prisma 4.x downloads native engine binaries during postinstall, but `yarn install` in a worktree may not trigger this correctly. If you see `PrismaClientInitializationError: Unable to require libquery_engine-darwin-arm64.dylib.node`, copy the binary from the main repo: `cp <main-repo>/node_modules/@prisma/engines/libquery_engine-darwin-arm64.dylib.node node_modules/@prisma/engines/`
118+
117119
### SQLite database locations
118120

119121
CodeV uses its own SQLite database (via Prisma) for storing user settings, AI assistant settings, and conversation history. Recent projects data is read directly from VS Code/Cursor's `state.vscdb`.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "CodeV",
33
"productName": "CodeV",
4-
"version": "1.0.56",
4+
"version": "1.0.57",
55
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
66
"repository": {
77
"type": "git",
@@ -74,7 +74,7 @@
7474
"@prisma/client": "^4.16.1",
7575
"@xterm/addon-fit": "^0.11.0",
7676
"@xterm/xterm": "^6.0.0",
77-
"axios": "^1.14.0",
77+
"axios": "1.14.0",
7878
"better-sqlite3": "^12.8.0",
7979
"class-transformer": "0.5.1",
8080
"class-validator": "0.14.0",

src/TrayGenerator.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@ export const isMasStr = isMAS() ? 'mas' : 'nonMas';
1010

1111
// ref:
1212
// https://blog.logrocket.com/building-a-menu-bar-application-with-electron-and-react/
13+
export interface ShortcutSettings {
14+
quickSwitcher: string;
15+
aiInsight: string;
16+
aiChat: string;
17+
}
18+
1319
export class TrayGenerator {
1420
tray: Tray;
1521
attachedWindow: BrowserWindow;
1622
onTrayClickCallback: any;
1723
title: string;
24+
shortcuts: ShortcutSettings = {
25+
quickSwitcher: 'Command+Control+R',
26+
aiInsight: 'Command+Control+E',
27+
aiChat: 'Command+Control+C',
28+
};
1829

1930
constructor(
2031
attachedWindow: BrowserWindow,
@@ -28,6 +39,14 @@ export class TrayGenerator {
2839
this.createTray(title);
2940
}
3041

42+
updateShortcuts = (shortcuts: ShortcutSettings) => {
43+
this.shortcuts = shortcuts;
44+
};
45+
46+
private acceleratorToMenuLabel = (acc: string): string => {
47+
return acc.replace(/Command/g, 'Cmd').replace(/Control/g, 'Ctrl');
48+
};
49+
3150
onTrayClick = () => {
3251
if (this.onTrayClickCallback) {
3352
this.onTrayClickCallback();
@@ -71,10 +90,10 @@ export class TrayGenerator {
7190
{
7291
label: 'Keyboard Shortcuts',
7392
submenu: [
74-
{ label: 'Cmd+Ctrl+R: Open CodeV Quick Switcher', enabled: false },
93+
{ label: `${this.acceleratorToMenuLabel(this.shortcuts.quickSwitcher)}: Open CodeV Quick Switcher`, enabled: false },
7594
{ label: 'Tab: Switch Projects / Sessions', enabled: false },
76-
{ label: 'Cmd+Ctrl+E: AI Assistant Insight', enabled: false },
77-
{ label: 'Cmd+Ctrl+C: AI Assistant Smart Chat', enabled: false },
95+
{ label: `${this.acceleratorToMenuLabel(this.shortcuts.aiInsight)}: AI Assistant Insight`, enabled: false },
96+
{ label: `${this.acceleratorToMenuLabel(this.shortcuts.aiChat)}: AI Assistant Smart Chat`, enabled: false },
7897
],
7998
},
8099
{ type: 'separator' },

src/claude-session-utility.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -865,18 +865,25 @@ end tell`;
865865
* Reads each session's JSONL file and greps for "custom-title" entries.
866866
* Returns a map of sessionId -> customTitle.
867867
*/
868+
export interface PRLinkInfo {
869+
prNumber: number;
870+
prUrl: string;
871+
}
872+
868873
export interface SessionEnrichment {
869874
titles: Map<string, string>;
870875
branches: Map<string, string>;
876+
prLinks: Map<string, PRLinkInfo>;
871877
}
872878

873-
// Cache for branches
879+
// Cache for branches and PR links
874880
let cachedBranches: Map<string, string> | null = null;
881+
let cachedPRLinks: Map<string, PRLinkInfo> | null = null;
875882

876883
export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise<SessionEnrichment> => {
877884
const now = Date.now();
878-
if (cachedCustomTitles && cachedBranches && (now - titlesCacheTimestamp) < CACHE_TTL_MS) {
879-
return { titles: cachedCustomTitles, branches: cachedBranches };
885+
if (cachedCustomTitles && cachedBranches && cachedPRLinks && (now - titlesCacheTimestamp) < CACHE_TTL_MS) {
886+
return { titles: cachedCustomTitles, branches: cachedBranches, prLinks: cachedPRLinks };
880887
}
881888

882889
const { exec } = require('child_process');
@@ -889,6 +896,7 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise<
889896

890897
const titles = new Map<string, string>();
891898
const branches = new Map<string, string>();
899+
const prLinks = new Map<string, PRLinkInfo>();
892900
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
893901

894902
const promises = sessions.map(async (session) => {
@@ -897,10 +905,11 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise<
897905

898906
if (!fs.existsSync(jsonlPath)) return;
899907

900-
// Run title grep and branch tail in parallel for each file
901-
const [titleOutput, branchOutput] = await Promise.all([
908+
// Run title, branch, and PR link greps in parallel for each file
909+
const [titleOutput, branchOutput, prLinkOutput] = await Promise.all([
902910
execPromise(`grep '"type":"custom-title"' "${jsonlPath}" 2>/dev/null | tail -1`),
903911
execPromise(`tail -n 5 "${jsonlPath}" 2>/dev/null | grep -o '"gitBranch":"[^"]*"' | tail -1`),
912+
execPromise(`grep '"type":"pr-link"' "${jsonlPath}" 2>/dev/null | tail -1`),
904913
]);
905914

906915
if (titleOutput.trim()) {
@@ -919,14 +928,24 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise<
919928
branches.set(session.sessionId, match[1]);
920929
}
921930
}
931+
932+
if (prLinkOutput.trim()) {
933+
try {
934+
const parsed = JSON.parse(prLinkOutput.trim());
935+
if (parsed.prNumber && parsed.prUrl) {
936+
prLinks.set(session.sessionId, { prNumber: parsed.prNumber, prUrl: parsed.prUrl });
937+
}
938+
} catch {}
939+
}
922940
});
923941

924942
await Promise.all(promises);
925943

926944
cachedCustomTitles = titles;
927945
cachedBranches = branches;
946+
cachedPRLinks = prLinks;
928947
titlesCacheTimestamp = now;
929-
return { titles, branches };
948+
return { titles, branches, prLinks };
930949
};
931950

932951
/**

src/electron-api.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ interface IElectronAPI {
4747
detectTerminalApps: (pidMap: Record<string, number>) => Promise<Record<string, string>>;
4848
openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void;
4949
copyClaudeSessionCommand: (sessionId: string, projectPath: string) => void;
50-
loadSessionEnrichment: (sessions: any[]) => Promise<{ titles: Record<string, string>; branches: Record<string, string> }>;
50+
loadSessionEnrichment: (sessions: any[]) => Promise<{ titles: Record<string, string>; branches: Record<string, string>; prLinks: Record<string, { prNumber: number; prUrl: string }> }>;
5151
loadLastAssistantResponses: (sessions: any[]) => Promise<Record<string, string>>;
5252
loadProjectBranches: (paths: string[]) => Promise<Record<string, string>>;
53+
detectActiveIDEProjects: () => Promise<string[]>;
5354

5455
// VS Code SQLite
5556
fetchVSCodeBasedIDESqlite: () => void;
@@ -58,6 +59,7 @@ interface IElectronAPI {
5859
onVSCodeBasedSqliteRecordDeleted: (callback: IpcCallback) => void;
5960

6061
// Window events
62+
openExternal: (url: string) => void;
6163
onFolderSelected: (callback: IpcCallback) => void;
6264
onWorkingFolderIterated: (callback: IpcCallback) => void;
6365
onFocusWindow: (callback: IpcCallback) => void;

src/main.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import settings from 'electron-settings';
1313
import { existsSync, readdirSync } from 'fs';
1414
import { DBPathMigrationManager } from './DBPathMigrationManager';
15-
import { isMAS, TrayGenerator } from './TrayGenerator';
15+
import { isMAS, ShortcutSettings, TrayGenerator } from './TrayGenerator';
1616
import { bootstrap } from './server/server';
1717
import { AIAssistantUIMode, isDebug } from './utility';
1818
import {
@@ -592,6 +592,11 @@ ipcMain.on('hide-app', (event) => {
592592
hideSwitcherWindow();
593593
});
594594

595+
ipcMain.on('open-external', (_event, url: string) => {
596+
const { shell } = require('electron');
597+
shell.openExternal(url);
598+
});
599+
595600
ipcMain.on('close-app-click', async (event) => {
596601
// Hide windows before quit to prevent white flash from xterm container
597602
switcherWindow?.hide();
@@ -630,7 +635,7 @@ const API_URL = 'http://localhost:55688';
630635

631636
// Import the function to update IDE mode and IDE enum
632637
import { IDEMode } from './ide-enum';
633-
import { updateCurrentIDEMode } from './vscode-based-ide-utility';
638+
import { detectActiveIDEProjects, updateCurrentIDEMode } from './vscode-based-ide-utility';
634639

635640
// Track user settings
636641
let userSettings = {
@@ -1533,15 +1538,22 @@ const trayToggleEvtHandler = async () => {
15331538
}
15341539
};
15351540

1541+
const getCurrentShortcuts = async (): Promise<ShortcutSettings> => ({
1542+
quickSwitcher: ((await settings.get('shortcut-quickSwitcher')) as string) || DEFAULT_SHORTCUTS.quickSwitcher,
1543+
aiInsight: ((await settings.get('shortcut-aiInsight')) as string) || DEFAULT_SHORTCUTS.aiInsight,
1544+
aiChat: ((await settings.get('shortcut-aiChat')) as string) || DEFAULT_SHORTCUTS.aiChat,
1545+
});
1546+
1547+
const syncTrayShortcuts = async () => {
1548+
if (tray) tray.updateShortcuts(await getCurrentShortcuts());
1549+
};
1550+
15361551
await registerAllShortcuts();
1552+
await syncTrayShortcuts();
15371553

15381554
// IPC handler: get all shortcut accelerators
15391555
ipcMain.handle('get-shortcuts', async () => {
1540-
return {
1541-
quickSwitcher: ((await settings.get('shortcut-quickSwitcher')) as string) || DEFAULT_SHORTCUTS.quickSwitcher,
1542-
aiInsight: ((await settings.get('shortcut-aiInsight')) as string) || DEFAULT_SHORTCUTS.aiInsight,
1543-
aiChat: ((await settings.get('shortcut-aiChat')) as string) || DEFAULT_SHORTCUTS.aiChat,
1544-
};
1556+
return await getCurrentShortcuts();
15451557
});
15461558

15471559
// IPC handler: temporarily unregister a shortcut (while editing)
@@ -1578,6 +1590,7 @@ const trayToggleEvtHandler = async () => {
15781590
const registered = globalShortcut.register(accelerator, callback);
15791591
if (registered) {
15801592
await settings.set(`shortcut-${key}`, accelerator);
1593+
await syncTrayShortcuts();
15811594
return { success: true };
15821595
} else {
15831596
// Re-register the old shortcut since the new one failed
@@ -1592,6 +1605,7 @@ const trayToggleEvtHandler = async () => {
15921605
await settings.unset(`shortcut-${key}`);
15931606
}
15941607
await registerAllShortcuts();
1608+
await syncTrayShortcuts();
15951609
return DEFAULT_SHORTCUTS;
15961610
});
15971611
})();
@@ -1800,10 +1814,11 @@ ipcMain.on('copy-claude-session-command', (_event, sessionId: string, projectPat
18001814
});
18011815

18021816
ipcMain.handle('load-session-enrichment', async (_event, sessions: any[]) => {
1803-
const { titles, branches } = await loadSessionEnrichment(sessions);
1817+
const { titles, branches, prLinks } = await loadSessionEnrichment(sessions);
18041818
return {
18051819
titles: Object.fromEntries(titles),
18061820
branches: Object.fromEntries(branches),
1821+
prLinks: Object.fromEntries(prLinks),
18071822
};
18081823
});
18091824

@@ -1833,4 +1848,9 @@ ipcMain.handle('load-project-branches', async (_event, paths: string[]) => {
18331848
return results;
18341849
});
18351850

1851+
ipcMain.handle('detect-active-ide-projects', async () => {
1852+
const folderNames = await detectActiveIDEProjects();
1853+
return Array.from(folderNames);
1854+
});
1855+
18361856
app.dock.hide();

src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
5353
loadSessionEnrichment: (sessions: any[]) => ipcRenderer.invoke('load-session-enrichment', sessions),
5454
loadLastAssistantResponses: (sessions: any[]) => ipcRenderer.invoke('load-last-assistant-responses', sessions),
5555
loadProjectBranches: (paths: string[]) => ipcRenderer.invoke('load-project-branches', paths),
56+
detectActiveIDEProjects: () => ipcRenderer.invoke('detect-active-ide-projects'),
5657

5758
/** for reading VS Code built-in sqlite */
5859
fetchVSCodeBasedIDESqlite: () => ipcRenderer.send('fetch-vscode-based-sqlite'),
@@ -68,6 +69,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
6869

6970
onWorkingFolderIterated: (callback: any) =>
7071
ipcRenderer.on('working-folder-iterated', callback),
72+
openExternal: (url: string) => ipcRenderer.send('open-external', url),
7173
onFocusWindow: (callback: any) => ipcRenderer.on('window-focus', callback),
7274
onXWinNotFound: (callback: any) => ipcRenderer.on('xwin-not-found', callback),
7375

0 commit comments

Comments
 (0)