Skip to content

Commit a20b7df

Browse files
feat(projects): GitHub repo link on cards + compact list view (#20)
GitHub octocat button on each card/list row for repos with a github.com remote — clicking opens the repo page. The renderer passes only the project path (project:openRepo); main re-reads + validates the remote (https + github.com) before shell.openExternal, leaving the external-URL allowlist untouched so a compromised renderer can't open an arbitrary URL. Plus a cards/list view toggle in the projects toolbar (persisted): a dense one-line-per-project list for scanning many projects fast. - gitParse.ts: parseRemoteUrl (scp/https/ssh github forms -> browsable URL) - gitInfo.ts: remote.origin.url -> GitInfo.repoUrl + getRepoUrl() helper - externalUrl.ts: isSafeRepoUrl (https github.com, defense-in-depth) - types/projects: repoUrl threaded into the view model - ipc project:openRepo (path-guarded) + settings:setViewMode; store viewMode - projectsView: octocat button, makeMenuWrap shared by card+row, makeRow, view toggle; i18n proj.open_github/view_cards/view_list - tests +10 (172 total); Playwright QA 0 console/page errors Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7e6cf0c commit a20b7df

22 files changed

Lines changed: 328 additions & 48 deletions

qa/screenshot.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ await shot('new-project-modal-error');
7676
await win.keyboard.press('Escape').catch(() => {});
7777
await win.waitForTimeout(200);
7878

79+
// Compact list view toggle (+ GitHub octocat on rows for repos with a github remote)
80+
await win.click('#view-list').catch(() => {});
81+
await win.waitForSelector('#cards.as-list .prow', { timeout: 5000 }).catch(() => {});
82+
await shot('projects-list-view');
83+
await win.click('#view-cards').catch(() => {});
84+
await win.waitForTimeout(300);
85+
7986
// Narrow window to check responsive card grid
8087
await win.setViewportSize({ width: 520, height: 760 }).catch(() => {});
8188
await shot('projects-narrow');

src/main/gitInfo.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { getGitInfo } from './gitInfo';
2+
import { getGitInfo, getRepoUrl } from './gitInfo';
33

44
// Fake runner keyed by the git subcommand.
55
function fakeRunner(map: Record<string, string>) {
@@ -8,6 +8,7 @@ function fakeRunner(map: Record<string, string>) {
88
if (args.includes('rev-list')) return map.ahead ?? '';
99
if (args.includes('log')) return map.log ?? '';
1010
if (args.includes('status')) return map.status ?? '';
11+
if (args.includes('config')) return map.remote ?? '';
1112
return '';
1213
};
1314
}
@@ -19,16 +20,30 @@ describe('getGitInfo', () => {
1920
log: '1717287840|scaffold\n',
2021
status: ' M a.ts\n?? b.ts\n',
2122
ahead: '2\n',
23+
remote: 'git@github.com:writingdeveloper/devdeck.git\n',
2224
});
2325
expect(await getGitInfo('C:\\g\\x', run)).toEqual({
2426
branch: 'main',
2527
lastCommitMs: 1717287840000,
2628
lastSubject: 'scaffold',
2729
uncommitted: 2,
2830
ahead: 2,
31+
repoUrl: 'https://github.com/writingdeveloper/devdeck',
2932
});
3033
});
3134

35+
it('reports null repoUrl when there is no github remote', async () => {
36+
const run = fakeRunner({ branch: 'main\n', remote: 'git@gitlab.com:o/r.git\n' });
37+
expect((await getGitInfo('C:\\g\\x', run)).repoUrl).toBeNull();
38+
});
39+
40+
it('getRepoUrl returns the normalized URL, or null when the config call throws', async () => {
41+
const run = fakeRunner({ remote: 'https://github.com/o/r.git\n' });
42+
expect(await getRepoUrl('C:\\g\\x', run)).toBe('https://github.com/o/r');
43+
const throwing = async (args: string[]): Promise<string> => { if (args.includes('config')) throw new Error('x'); return ''; };
44+
expect(await getRepoUrl('C:\\g\\x', throwing)).toBeNull();
45+
});
46+
3247
it('reports null ahead when there is no upstream (rev-list throws)', async () => {
3348
const run = async (args: string[]): Promise<string> => {
3449
if (args.includes('rev-list')) throw new Error('no upstream');

src/main/gitInfo.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFile } from 'node:child_process';
22
import { promisify } from 'node:util';
33
import type { GitInfo } from '../shared/types';
4-
import { parseBranch, parseLastCommit, parsePorcelainCount, parseAheadCount } from '../shared/gitParse';
4+
import { parseBranch, parseLastCommit, parsePorcelainCount, parseAheadCount, parseRemoteUrl } from '../shared/gitParse';
55

66
const execFileAsync = promisify(execFile);
77

@@ -21,11 +21,12 @@ async function safe(run: GitRunner, args: string[]): Promise<string | null> {
2121
}
2222

2323
export async function getGitInfo(dir: string, run: GitRunner = defaultRunner): Promise<GitInfo> {
24-
const [branchOut, logOut, statusOut, aheadOut] = await Promise.all([
24+
const [branchOut, logOut, statusOut, aheadOut, remoteOut] = await Promise.all([
2525
safe(run, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD']),
2626
safe(run, ['-C', dir, 'log', '-1', '--format=%ct|%s']),
2727
safe(run, ['-C', dir, 'status', '--porcelain']),
2828
safe(run, ['-C', dir, 'rev-list', '--count', '@{upstream}..HEAD']),
29+
safe(run, ['-C', dir, 'config', '--get', 'remote.origin.url']),
2930
]);
3031
const { lastCommitMs, lastSubject } = parseLastCommit(logOut ?? '');
3132
return {
@@ -34,5 +35,12 @@ export async function getGitInfo(dir: string, run: GitRunner = defaultRunner): P
3435
lastSubject,
3536
uncommitted: parsePorcelainCount(statusOut ?? ''),
3637
ahead: aheadOut == null ? null : parseAheadCount(aheadOut),
38+
repoUrl: parseRemoteUrl(remoteOut ?? ''),
3739
};
3840
}
41+
42+
/** Read just the GitHub repo URL for a directory (used on-demand when opening the repo page). */
43+
export async function getRepoUrl(dir: string, run: GitRunner = defaultRunner): Promise<string | null> {
44+
const out = await safe(run, ['-C', dir, 'config', '--get', 'remote.origin.url']);
45+
return parseRemoteUrl(out ?? '');
46+
}

src/main/ipc.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { stat } from 'node:fs/promises';
55
import type { Store } from './store';
66
import { applyOpenAtLogin, effectiveOpenAtLogin } from './autostart';
77
import { scanFolders, isRepo } from './scanner';
8-
import { getGitInfo } from './gitInfo';
8+
import { getGitInfo, getRepoUrl } from './gitInfo';
99
import { getProvider, availableAgents } from './agents';
1010
import type { AgentId, Folder } from '../shared/types';
1111
import { isAllowedPath } from '../shared/pathGuard';
12-
import { isAllowedExternalUrl } from '../shared/externalUrl';
12+
import { isAllowedExternalUrl, isSafeRepoUrl } from '../shared/externalUrl';
1313
import { buildProjectList } from './projects';
1414
import { createProject } from './createProject';
1515
import { openProjects, openInEditor } from './launcher';
@@ -81,12 +81,16 @@ export function registerIpc(cfg: IpcConfig): void {
8181
ipcMain.handle('settings:get', () => ({
8282
baseDir: effBaseDir(), thresholds: effThresholds(), language: cfg.store.getLanguage() ?? cfg.defaultLanguage,
8383
openAtLogin: effectiveOpenAtLogin(cfg.store.getOpenAtLogin()), platform: process.platform,
84+
viewMode: cfg.store.getViewMode(),
8485
}));
8586
ipcMain.handle('settings:setOpenAtLogin', (_e, enabled: boolean) => {
8687
const on = !!enabled;
8788
cfg.store.setOpenAtLogin(on);
8889
applyOpenAtLogin(on);
8990
});
91+
ipcMain.handle('settings:setViewMode', (_e, mode: string) => {
92+
cfg.store.setViewMode(mode === 'list' ? 'list' : 'cards');
93+
});
9094
ipcMain.handle('settings:setBaseDir', (_e, dir: string) => cfg.store.setBaseDir(String(dir).slice(0, 2000)));
9195
ipcMain.handle('settings:getFolders', () => effFolders());
9296
ipcMain.handle('settings:addFolder', async (_e, p: string) => {
@@ -178,6 +182,19 @@ export function registerIpc(cfg: IpcConfig): void {
178182
return createProject(effFolders(), String(parent), String(name));
179183
});
180184

185+
// Open the project's GitHub page. The renderer passes only the path (never a URL);
186+
// main re-reads the repo URL from git and validates it, so a compromised renderer
187+
// can't open an arbitrary external URL.
188+
ipcMain.handle('project:openRepo', async (_e, p: string) => {
189+
if (!isAllowedPath(effFolders(), p)) {
190+
cfg.sendError(`Path outside allowed folders: ${p}`);
191+
return;
192+
}
193+
const url = await getRepoUrl(p);
194+
if (url && isSafeRepoUrl(url)) await shell.openExternal(url);
195+
else cfg.sendError(`No GitHub remote found for: ${p}`);
196+
});
197+
181198
// Frameless-window controls (the title bar draws its own buttons).
182199
ipcMain.handle('win:minimize', () => cfg.win.minimize());
183200
ipcMain.handle('win:toggleMaximize', () => {

src/main/projects.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function deps(over: Partial<BuildDeps>): BuildDeps {
2020
lastSubject: 'x',
2121
uncommitted: 0,
2222
ahead: null,
23+
repoUrl: null,
2324
}),
2425
sessions: () => [],
2526
resumeCue: () => null,
@@ -59,7 +60,7 @@ describe('buildProjectList', () => {
5960
it('wires sessions + sessionCount and uses the latest session for activity', async () => {
6061
const NOW2 = NOW;
6162
const list = await buildProjectList(deps({
62-
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0, ahead: 4 }),
63+
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0, ahead: 4, repoUrl: 'https://github.com/acme/proj' }),
6364
sessions: (p) => p.endsWith('fresh')
6465
? [{ id: 'x', mtimeMs: NOW2 - 3_600_000, firstMessage: 'hi' }]
6566
: [],
@@ -70,6 +71,7 @@ describe('buildProjectList', () => {
7071
expect(fresh.lastSessionMs).toBe(NOW2 - 3_600_000);
7172
expect(fresh.stale.level).toBe('fresh');
7273
expect(fresh.ahead).toBe(4); // unpushed count threaded through
74+
expect(fresh.repoUrl).toBe('https://github.com/acme/proj'); // github remote threaded through
7375
});
7476

7577
it('derives resumeCue from the newest session via the dep', async () => {

src/main/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export async function buildProjectList(deps: BuildDeps): Promise<ProjectViewMode
4646
hidden: entry.hidden,
4747
lastOpened: entry.lastOpened,
4848
resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null,
49+
repoUrl: git.repoUrl,
4950
};
5051
}),
5152
);

src/main/store.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ describe('Store', () => {
7575
expect(new Store(file).getOpenAtLogin()).toBe(false);
7676
});
7777

78+
it('persists viewMode and defaults to cards', () => {
79+
const s = new Store(file);
80+
expect(s.getViewMode()).toBe('cards');
81+
s.setViewMode('list');
82+
expect(new Store(file).getViewMode()).toBe('list');
83+
s.setViewMode('cards');
84+
expect(new Store(file).getViewMode()).toBe('cards');
85+
});
86+
7887
it('round-trips folders: add, dedupe, remove', () => {
7988
const s = new Store(file);
8089
expect(s.getFolders()).toEqual([]);

src/main/store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { StoreEntry, Folder } from '../shared/types';
44

55
interface StateFile {
66
projects: Record<string, StoreEntry>;
7-
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean };
7+
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list' };
88
}
99

1010
const EMPTY: StoreEntry = {
@@ -87,6 +87,9 @@ export class Store {
8787
getOpenAtLogin(): boolean { return this.state.settings?.openAtLogin ?? false; }
8888
setOpenAtLogin(openAtLogin: boolean): void { this.state.settings = { ...(this.state.settings ?? {}), openAtLogin }; this.save(); }
8989

90+
getViewMode(): 'cards' | 'list' { return this.state.settings?.viewMode === 'list' ? 'list' : 'cards'; }
91+
setViewMode(viewMode: 'cards' | 'list'): void { this.state.settings = { ...(this.state.settings ?? {}), viewMode }; this.save(); }
92+
9093
setNote(path: string, note: string): void { this.mutate(path, { note }); }
9194
setPinned(path: string, pinned: boolean): void { this.mutate(path, { pinned }); }
9295
setHidden(path: string, hidden: boolean): void { this.mutate(path, { hidden }); }

src/preload/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ contextBridge.exposeInMainWorld('devdeck', {
2424
pickFolder: () => ipcRenderer.invoke('settings:pickFolder'),
2525
openFolder: (path: string) => ipcRenderer.invoke('project:openFolder', path),
2626
openEditor: (path: string) => ipcRenderer.invoke('project:openEditor', path),
27+
openRepo: (path: string) => ipcRenderer.invoke('project:openRepo', path),
2728
createProject: (parent: string, name: string) => ipcRenderer.invoke('project:create', parent, name),
29+
setViewMode: (mode: 'cards' | 'list') => ipcRenderer.invoke('settings:setViewMode', mode),
2830
onUpdate: (cb: (p: import('../shared/update').UpdatePayload) => void) =>
2931
ipcRenderer.on('devdeck:update', (_e, p) => cb(p as import('../shared/update').UpdatePayload)),
3032
downloadUpdate: () => ipcRenderer.invoke('update:download'),

src/renderer/global.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ declare global {
1515
getAgent(): Promise<import('../shared/types').AgentId>;
1616
setAgent(id: string): Promise<void>;
1717
availableAgents(): Promise<import('../shared/types').AgentId[]>;
18-
getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string; openAtLogin: boolean; platform: string }>;
18+
getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string; openAtLogin: boolean; platform: string; viewMode: 'cards' | 'list' }>;
1919
setBaseDir(dir: string): Promise<void>;
2020
getFolders(): Promise<import('../shared/types').Folder[]>;
2121
addFolder(path: string): Promise<import('../shared/types').Folder[]>;
@@ -25,7 +25,9 @@ declare global {
2525
pickFolder(): Promise<string | null>;
2626
openFolder(path: string): Promise<void>;
2727
openEditor(path: string): Promise<void>;
28+
openRepo(path: string): Promise<void>;
2829
createProject(parent: string, name: string): Promise<import('../main/createProject').CreateProjectResult>;
30+
setViewMode(mode: 'cards' | 'list'): Promise<void>;
2931
onUpdate(cb: (p: import('../shared/update').UpdatePayload) => void): void;
3032
downloadUpdate(): Promise<void>;
3133
installUpdate(): Promise<void>;

0 commit comments

Comments
 (0)