From 27d930a7288771a51c5e2877ead63dc7db6b875f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 26 Jun 2026 06:35:51 +0000 Subject: [PATCH] feat(hub): exclude projects via right-click menu on project cards Add the ability to hide projects from the hub without disabling them. Excluded projects stay enabled and keep syncing in the background, but are filtered out of the hub home, summary aggregates, project switcher, prev/next navigation, sync-all, and the recent/todo/queue feeds. Right-clicking a project card opens a context menu with "Sync this project now" and "Exclude this project". Exclusions persist to hub.json and broadcast to every open client. Hidden projects can be restored from the Manage projects settings panel. --- src/devframe/hub-mode.ts | 15 +++++ src/devframe/rpc/capabilities.ts | 7 ++- src/devframe/rpc/helpers.ts | 8 ++- src/devframe/rpc/hub-context.ts | 4 ++ src/devframe/rpc/hub-disable.ts | 3 + src/devframe/rpc/hub-set-excluded.ts | 35 +++++++++++ src/devframe/rpc/index.ts | 2 + src/devframe/rpc/list-projects.ts | 7 ++- src/devframe/rpc/types.ts | 8 +++ src/devframe/rpc/utils.ts | 5 ++ src/hub/config.test.ts | 69 +++++++++++++++++++++ src/hub/config.ts | 35 +++++++++++ ui/components/hub/Home.vue | 48 +++++++++++---- ui/components/hub/ProjectContextMenu.vue | 76 ++++++++++++++++++++++++ ui/components/hub/ProjectSwitcher.vue | 6 +- ui/components/panel/SettingsProjects.vue | 54 +++++++++++++++++ ui/composables/useAppCommands.ts | 33 +++++++++- ui/composables/useHubQueue.ts | 13 ++-- ui/composables/useHubRecent.ts | 11 +++- ui/composables/useHubState.ts | 24 +++++++- ui/composables/useHubTodos.ts | 12 +++- ui/composables/useHubUiState.ts | 37 +++++++++++- ui/composables/useWhenContext.ts | 6 +- 23 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 src/devframe/rpc/hub-set-excluded.ts create mode 100644 ui/components/hub/ProjectContextMenu.vue diff --git a/src/devframe/hub-mode.ts b/src/devframe/hub-mode.ts index 878c6a3..288aa25 100644 --- a/src/devframe/hub-mode.ts +++ b/src/devframe/hub-mode.ts @@ -9,6 +9,7 @@ import { addHubRoot, loadHubConfig, setEnabledProjects, + setExcludedProjects, } from '../hub/config' import { slugifyRepoName } from '../server/portless' import { buildProjectContext, closeProjectContext } from './project-factory' @@ -41,6 +42,9 @@ export async function setupHubMode( const homeDir = options.homeDir const roots = new Set() const projects = new Map() + // Absolute paths of enabled projects hidden from the hub. Kept loaded; the + // client filters them out of hub surfaces. + const excluded = new Set() // Single in-flight promise serializes config-mutating operations so two // concurrent RPCs don't race on the shared hub.json file. @@ -127,6 +131,8 @@ export async function setupHubMode( config = await addHubRoot({ homeDir, path: launchCwd }) for (const root of config.roots) roots.add(root) + for (const entry of config.excludedProjects ?? []) + excluded.add(entry.path) await loadEnabledProjects(config.enabledProjects.map(p => p.path)) const registry: ProjectRegistry = { @@ -148,6 +154,13 @@ export async function setupHubMode( }) } + async function persistExcluded(): Promise { + await setExcludedProjects({ + homeDir, + paths: Array.from(excluded), + }) + } + setProjectRegistry(devframeCtx, registry) setHubContext(devframeCtx, { devframeCtx, @@ -155,12 +168,14 @@ export async function setupHubMode( launchCwd, roots, projects, + excluded, withLock, buildHubInfo, broadcastProjectsChange, broadcastHubInfoChange, loadProjectByPath, persistEnabled, + persistExcluded, autoSync, }) registerGhfsRpc(devframeCtx) diff --git a/src/devframe/rpc/capabilities.ts b/src/devframe/rpc/capabilities.ts index cf1176c..8c33dae 100644 --- a/src/devframe/rpc/capabilities.ts +++ b/src/devframe/rpc/capabilities.ts @@ -1,18 +1,21 @@ import { defineRpcFunction } from 'devframe' import { GHFS_VERSION } from '../../meta' import { summarizeProject } from './helpers' -import { getProjectRegistry } from './utils' +import { getProjectRegistry, tryGetHubContext } from './utils' export const capabilities = defineRpcFunction({ name: 'ghfs:capabilities', type: 'static', setup: (context) => { const registry = getProjectRegistry(context) + const hub = tryGetHubContext(context) return { handler: async () => ({ mode: registry.mode, ghfsVersion: GHFS_VERSION, - projects: await Promise.all(registry.listProjects().map(summarizeProject)), + projects: await Promise.all( + registry.listProjects().map(ctx => summarizeProject(ctx, { excluded: hub?.excluded.has(ctx.path) ?? false })), + ), }), } }, diff --git a/src/devframe/rpc/helpers.ts b/src/devframe/rpc/helpers.ts index 726febf..554554f 100644 --- a/src/devframe/rpc/helpers.ts +++ b/src/devframe/rpc/helpers.ts @@ -59,7 +59,12 @@ export async function buildRepoMeta(ctx: ProjectContext): Promise { } } -export async function summarizeProject(ctx: ProjectContext): Promise { +export interface SummarizeProjectOptions { + /** Whether the user has hidden this project from the hub. */ + excluded?: boolean +} + +export async function summarizeProject(ctx: ProjectContext, options: SummarizeProjectOptions = {}): Promise { const [repo, syncState, snapshot] = await Promise.all([ buildRepoMeta(ctx), loadSyncState(ctx.storageDirAbsolute), @@ -107,6 +112,7 @@ export async function summarizeProject(ctx: ProjectContext): Promise projects: Map + /** Absolute paths of enabled projects the user has hidden from the hub. */ + excluded: Set withLock: (fn: () => Promise) => Promise buildHubInfo: () => HubInfo broadcastProjectsChange: () => void broadcastHubInfoChange: () => void loadProjectByPath: (path: string) => Promise persistEnabled: () => Promise + /** Persist the current `excluded` set to hub.json. */ + persistExcluded: () => Promise autoSync: { setInterval: (ms: number | undefined) => void, close: () => void } } diff --git a/src/devframe/rpc/hub-disable.ts b/src/devframe/rpc/hub-disable.ts index 253bf6b..0f82156 100644 --- a/src/devframe/rpc/hub-disable.ts +++ b/src/devframe/rpc/hub-disable.ts @@ -16,6 +16,9 @@ export const hubDisable = defineRpcFunction({ hub.projects.delete(id) await closeProjectContext(ctx) await hub.persistEnabled() + // A disabled project should not linger in the excluded set. + if (hub.excluded.delete(ctx.path)) + await hub.persistExcluded() hub.broadcastProjectsChange() return { removed: true } }) diff --git a/src/devframe/rpc/hub-set-excluded.ts b/src/devframe/rpc/hub-set-excluded.ts new file mode 100644 index 0000000..2763ec4 --- /dev/null +++ b/src/devframe/rpc/hub-set-excluded.ts @@ -0,0 +1,35 @@ +import { defineRpcFunction } from 'devframe' +import { getHubContext } from './utils' + +/** + * Hide (or restore) an enabled project from the hub. Excluded projects stay + * loaded and keep syncing — the exclusion only filters them out of the hub + * home, aggregates, and recent/todo/queue views. Persisted to hub.json and + * broadcast so every open client refreshes its project list. + */ +export const hubSetExcluded = defineRpcFunction({ + name: 'ghfs:hub-set-excluded', + type: 'action', + setup: (context) => { + const hub = getHubContext(context) + return { + handler: async (id: string, excluded: boolean): Promise<{ excluded: boolean }> => { + return hub.withLock(async () => { + const ctx = hub.projects.get(id) + if (!ctx) + return { excluded: false } + const had = hub.excluded.has(ctx.path) + if (excluded) + hub.excluded.add(ctx.path) + else + hub.excluded.delete(ctx.path) + if (had !== excluded) { + await hub.persistExcluded() + hub.broadcastProjectsChange() + } + return { excluded } + }) + }, + } + }, +}) diff --git a/src/devframe/rpc/index.ts b/src/devframe/rpc/index.ts index 245e1ce..a2db796 100644 --- a/src/devframe/rpc/index.ts +++ b/src/devframe/rpc/index.ts @@ -20,6 +20,7 @@ import { hubRecentItems } from './hub-recent-items' import { hubRemoveRoot } from './hub-remove-root' import { hubScan } from './hub-scan' import { hubSeenHistory } from './hub-seen-history' +import { hubSetExcluded } from './hub-set-excluded' import { hubSetSettings } from './hub-set-settings' import { hubSettings } from './hub-settings' import { hubTodos } from './hub-todos' @@ -71,6 +72,7 @@ export const rpcFunctions = [ hubScan, hubEnable, hubDisable, + hubSetExcluded, hubAddRoot, hubRemoveRoot, hubSettings, diff --git a/src/devframe/rpc/list-projects.ts b/src/devframe/rpc/list-projects.ts index 2b7f020..a4116d3 100644 --- a/src/devframe/rpc/list-projects.ts +++ b/src/devframe/rpc/list-projects.ts @@ -1,14 +1,17 @@ import { defineRpcFunction } from 'devframe' import { summarizeProject } from './helpers' -import { getProjectRegistry } from './utils' +import { getProjectRegistry, tryGetHubContext } from './utils' export const listProjects = defineRpcFunction({ name: 'ghfs:list-projects', type: 'query', setup: (context) => { const registry = getProjectRegistry(context) + const hub = tryGetHubContext(context) return { - handler: async () => Promise.all(registry.listProjects().map(summarizeProject)), + handler: async () => Promise.all( + registry.listProjects().map(ctx => summarizeProject(ctx, { excluded: hub?.excluded.has(ctx.path) ?? false })), + ), } }, }) diff --git a/src/devframe/rpc/types.ts b/src/devframe/rpc/types.ts index 1293aae..07ad616 100644 --- a/src/devframe/rpc/types.ts +++ b/src/devframe/rpc/types.ts @@ -27,6 +27,13 @@ export interface ProjectSummary { lastSyncedAt?: string /** Most recent `item.updatedAt` across the project's tracked items. */ lastActivityAt?: string + /** + * True when the user has hidden this project from the hub. Excluded projects + * stay enabled and keep syncing; the client filters them out of the hub home, + * aggregates, and recent/todo/queue views. Always `false` in single-project + * (ui) mode. + */ + excluded?: boolean /** * Repository label registry (names, colors, descriptions) from the most * recent repo snapshot. Consumed by hub-level views (recent, todos) to @@ -219,6 +226,7 @@ export interface GhfsServerFunctions { 'ghfs:hub-scan': () => Promise 'ghfs:hub-enable': (path: string) => Promise<{ id: string }> 'ghfs:hub-disable': (id: string) => Promise<{ removed: boolean }> + 'ghfs:hub-set-excluded': (id: string, excluded: boolean) => Promise<{ excluded: boolean }> 'ghfs:hub-add-root': (path: string) => Promise 'ghfs:hub-remove-root': (path: string) => Promise 'ghfs:hub-recent-items': (limit?: number) => Promise diff --git a/src/devframe/rpc/utils.ts b/src/devframe/rpc/utils.ts index ee6d17c..ad1f47e 100644 --- a/src/devframe/rpc/utils.ts +++ b/src/devframe/rpc/utils.ts @@ -26,6 +26,11 @@ export function getHubContext(ctx: DevframeNodeContext): HubRpcContext { return hub } +/** Like {@link getHubContext} but returns null in single-project (ui) mode. */ +export function tryGetHubContext(ctx: DevframeNodeContext): HubRpcContext | null { + return hubMap.get(ctx) ?? null +} + export function setHubContext(ctx: DevframeNodeContext, hub: HubRpcContext): void { hubMap.set(ctx, hub) } diff --git a/src/hub/config.test.ts b/src/hub/config.test.ts index 5e6f0ed..617e901 100644 --- a/src/hub/config.test.ts +++ b/src/hub/config.test.ts @@ -9,6 +9,7 @@ import { resolveHubConfigPath, saveHubConfig, setEnabledProjects, + setExcludedProjects, setHubAutoSyncInterval, setHubCommentTemplates, setHubSwrSettings, @@ -141,6 +142,74 @@ describe('hub config', () => { expect(config.roots).toEqual(['/a']) }) + it('round-trips excludedProjects', async () => { + const homeDir = await makeHome() + const config = await setExcludedProjects({ + homeDir, + paths: ['/a/hidden-1', '/a/hidden-2', '/a/hidden-1'], + }) + expect(config.excludedProjects).toEqual([ + { path: '/a/hidden-1' }, + { path: '/a/hidden-2' }, + ]) + const reloaded = await loadHubConfig({ homeDir }) + expect(reloaded.excludedProjects).toEqual([ + { path: '/a/hidden-1' }, + { path: '/a/hidden-2' }, + ]) + }) + + it('setExcludedProjects preserves enabledProjects and roots', async () => { + const homeDir = await makeHome() + await saveHubConfig({ + homeDir, + config: { roots: ['/a'], enabledProjects: [{ path: '/a/keep' }, { path: '/a/hide' }] }, + }) + const config = await setExcludedProjects({ homeDir, paths: ['/a/hide'] }) + expect(config.roots).toEqual(['/a']) + expect(config.enabledProjects).toEqual([{ path: '/a/keep' }, { path: '/a/hide' }]) + expect(config.excludedProjects).toEqual([{ path: '/a/hide' }]) + }) + + it('omits excludedProjects from disk when never set', async () => { + const homeDir = await makeHome() + await saveHubConfig({ homeDir, config: { roots: ['/a'], enabledProjects: [] } }) + const raw = JSON.parse(await readFile(resolveHubConfigPath({ homeDir }), 'utf8')) + expect('excludedProjects' in raw).toBe(false) + }) + + it('distinguishes never-set from explicitly-empty excludedProjects', async () => { + const homeDir = await makeHome() + await saveHubConfig({ homeDir, config: { roots: ['/a'], enabledProjects: [] } }) + expect((await loadHubConfig({ homeDir })).excludedProjects).toBeUndefined() + await setExcludedProjects({ homeDir, paths: [] }) + expect((await loadHubConfig({ homeDir })).excludedProjects).toEqual([]) + }) + + it('removeHubRoot prunes excludedProjects under the removed root', async () => { + const homeDir = await makeHome() + await saveHubConfig({ + homeDir, + config: { + roots: ['/a', '/b'], + enabledProjects: [{ path: '/a/repo-1' }, { path: '/b/repo-2' }], + excludedProjects: [{ path: '/a/repo-1' }, { path: '/b/repo-2' }], + }, + }) + const config = await removeHubRoot({ homeDir, path: '/a' }) + expect(config.enabledProjects).toEqual([{ path: '/b/repo-2' }]) + expect(config.excludedProjects).toEqual([{ path: '/b/repo-2' }]) + }) + + it('preserves excludedProjects when other fields are mutated', async () => { + const homeDir = await makeHome() + await setExcludedProjects({ homeDir, paths: ['/a/hide'] }) + await setHubAutoSyncInterval({ homeDir, intervalMs: 120_000 }) + const reloaded = await loadHubConfig({ homeDir }) + expect(reloaded.excludedProjects).toEqual([{ path: '/a/hide' }]) + expect(reloaded.autoSyncIntervalMs).toBe(120_000) + }) + it('setHubAutoSyncInterval updates the global field', async () => { const homeDir = await makeHome() await addHubRoot({ homeDir, path: '/a' }) diff --git a/src/hub/config.ts b/src/hub/config.ts index 93e3ada..28ef5dd 100644 --- a/src/hub/config.ts +++ b/src/hub/config.ts @@ -15,6 +15,7 @@ const CommentTemplateSchema = v.object({ const ConfigSchema = v.object({ roots: v.optional(v.array(v.string())), enabledProjects: v.optional(v.array(ProjectEntrySchema)), + excludedProjects: v.optional(v.array(ProjectEntrySchema)), autoSyncIntervalMs: v.optional(v.pipe(v.number(), v.minValue(60_000), v.maxValue(3_600_000))), commentTemplates: v.optional(v.array(CommentTemplateSchema)), swrSyncEnabled: v.optional(v.boolean()), @@ -46,6 +47,13 @@ export interface HubConfig { roots: string[] /** Absolute paths of projects the user has enabled. Independent of `roots`. */ enabledProjects: HubProjectEntry[] + /** + * Absolute paths of enabled projects the user has hidden from the hub. These + * stay loaded and keep syncing — they are merely filtered out of the hub + * home, aggregates, and recent/todo/queue views until restored. A subset of + * `enabledProjects`. + */ + excludedProjects?: HubProjectEntry[] /** Global auto-sync interval applied to every project. */ autoSyncIntervalMs?: number /** Global comment templates surfaced in the composer's template picker. */ @@ -124,6 +132,8 @@ function parseConfig(raw: unknown): HubConfig { swrSyncEnabled: flat.output.swrSyncEnabled, swrCacheTimeoutMs: flat.output.swrCacheTimeoutMs, } + if (flat.output.excludedProjects !== undefined) + config.excludedProjects = dedupeProjects(flat.output.excludedProjects.map(e => ({ path: normalizePath(e.path) }))) if (flat.output.commentTemplates !== undefined) config.commentTemplates = flat.output.commentTemplates.map(t => ({ title: t.title, body: t.body })) return config @@ -167,6 +177,9 @@ export async function saveHubConfig(options: SaveHubConfigOptions): Promise ({ path: normalizePath(e.path) }))), + excludedProjects: options.config.excludedProjects === undefined + ? undefined + : dedupeProjects(options.config.excludedProjects.map(e => ({ path: normalizePath(e.path) }))), autoSyncIntervalMs: options.config.autoSyncIntervalMs, commentTemplates: options.config.commentTemplates, swrSyncEnabled: options.config.swrSyncEnabled, @@ -178,6 +191,8 @@ export async function saveHubConfig(options: SaveHubConfigOptions): Promise !isUnder(p.path, target)), } await saveHubConfig({ homeDir: options.homeDir, config: next }) return next @@ -234,6 +250,25 @@ export async function setEnabledProjects(options: SetEnabledProjectsOptions): Pr return next } +export interface SetExcludedProjectsOptions extends ResolveHubConfigPathOptions { + paths: string[] +} + +/** + * Overwrite the set of projects hidden from the hub. Pass the full list of + * excluded paths (deduped + normalized here). An empty array clears the + * exclusion (the field is then written as `[]`). + */ +export async function setExcludedProjects(options: SetExcludedProjectsOptions): Promise { + const current = await loadHubConfig(options) + const next: HubConfig = { + ...current, + excludedProjects: dedupeProjects(options.paths.map(p => ({ path: normalizePath(p) }))), + } + await saveHubConfig({ homeDir: options.homeDir, config: next }) + return next +} + export interface SetHubAutoSyncIntervalOptions extends ResolveHubConfigPathOptions { intervalMs: number | undefined } diff --git a/ui/components/hub/Home.vue b/ui/components/hub/Home.vue index c3c541d..9a37f2f 100644 --- a/ui/components/hub/Home.vue +++ b/ui/components/hub/Home.vue @@ -1,6 +1,6 @@