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 @@