diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c7c3c3e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode" + ], + "outFiles": [ + "${workspaceFolder}/packages/vscode/dist/**/*.mjs" + ] + } + ] +} diff --git a/packages/vscode/.gitignore b/packages/vscode/.gitignore new file mode 100644 index 0000000..730984a --- /dev/null +++ b/packages/vscode/.gitignore @@ -0,0 +1 @@ +generated-meta.ts diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..7a864c0 --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,24 @@ +# ghfs + +GitHub issues/PRs as filesystem, for offline view and operations in batch. Designed for human and agents. + +## Commands + + + +| Command | Title | +| ----------- | -------------- | +| `ghfs.sync` | ghfs: Sync Now | + + + +## Configs + + + +| Key | Description | Type | Default | +| ------------------------------- | ------------------------------ | --------- | ------- | +| `ghfs.autoSync.enabled` | Enable automatic periodic sync | `boolean` | `true` | +| `ghfs.autoSync.intervalMinutes` | Auto-sync interval in minutes | `number` | `1440` | + + diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..caadf7d --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,88 @@ +{ + "publisher": "Anthony Fu", + "name": "vscode-ghfs", + "displayName": "ghfs", + "type": "module", + "version": "0.0.3", + "packageManager": "pnpm@10.30.3", + "description": "GitHub issues/PRs as filesystem, for offline view and operations in batch. Designed for human and agents.", + "author": { + "name": "Vida Xie", + "email": "vida_2020@163.com", + "url": "https://github.com/9romise" + }, + "license": "MIT", + "funding": "https://github.com/sponsors/antfu", + "homepage": "https://github.com/antfu/ghfs/packages/vscode", + "repository": "https://github.com/antfu/ghfs", + "bugs": "https://github.com/antfu/ghfs/issues", + "keywords": [ + "github", + "issues", + "pull requests", + "markdown", + "cli" + ], + "categories": [ + "Other" + ], + "main": "./dist/index.mjs", + "browser": "./dist/index.mjs", + "icon": "res/logo.png", + "extensionKind": [ + "workspace" + ], + "files": [ + "LICENSE.md", + "dist/*", + "res/*" + ], + "engines": { + "vscode": "^1.101.0" + }, + "activationEvents": [ + "workspaceContains:.ghfs" + ], + "contributes": { + "commands": [ + { + "command": "ghfs.sync", + "title": "Sync Now", + "category": "ghfs" + } + ], + "configuration": { + "title": "ghfs", + "properties": { + "ghfs.autoSync.enabled": { + "type": "boolean", + "default": true, + "description": "Enable automatic periodic sync" + }, + "ghfs.autoSync.intervalMinutes": { + "type": "number", + "default": 1440, + "minimum": 1, + "description": "Auto-sync interval in minutes" + } + } + } + }, + "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", + "postinstall": "npm run update", + "update": "vscode-ext-gen --scope ghfs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "vscode:prepublish": "pnpm run build", + "publish": "npx @vscode/vsce publish --no-dependencies", + "package": "npx @vscode/vsce package --no-dependencies" + }, + "devDependencies": { + "@ghfs/cli": "workspace:*", + "@types/vscode": "1.101.0", + "reactive-vscode": "^1.0.0-beta.2", + "vscode-ext-gen": "1.6.0" + } +} diff --git a/packages/vscode/src/auth.ts b/packages/vscode/src/auth.ts new file mode 100644 index 0000000..acf54d1 --- /dev/null +++ b/packages/vscode/src/auth.ts @@ -0,0 +1,9 @@ +import { authentication } from 'vscode' + +export async function readTokenFromVSCode() { + const session = await authentication.getSession('github', ['repo'], { + createIfNone: true, + }) + + return session.accessToken +} diff --git a/packages/vscode/src/commands/sync.ts b/packages/vscode/src/commands/sync.ts new file mode 100644 index 0000000..2931ce0 --- /dev/null +++ b/packages/vscode/src/commands/sync.ts @@ -0,0 +1,38 @@ +import { ProgressLocation, window, workspace } from 'vscode' +import { runSync } from '../sync' + +export async function sync(cwd?: string): Promise { + if (!cwd) { + const activeEditor = window.activeTextEditor + const folder = activeEditor + ? workspace.getWorkspaceFolder(activeEditor.document.uri) + : workspace.workspaceFolders?.[0] + + if (!folder) { + window.showWarningMessage('ghfs: Sync failed - workspace folder not found.') + return + } + + cwd = folder.uri.path + } + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: 'ghfs: Syncing repository...', + cancellable: true, + }, + async () => { + try { + const summary = await runSync({ cwd }) + window.showInformationMessage( + `ghfs: Sync finished — ${summary.updatedIssues} issues and ${summary.updatedPulls} PRs updated.`, + ) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + window.showErrorMessage(`ghfs: Sync failed — ${message}`) + } + }, + ) +} diff --git a/packages/vscode/src/composables/auto-sync.ts b/packages/vscode/src/composables/auto-sync.ts new file mode 100644 index 0000000..af850e1 --- /dev/null +++ b/packages/vscode/src/composables/auto-sync.ts @@ -0,0 +1,101 @@ +import type { Uri } from 'vscode' +import { computed, shallowRef, useWindowState, watchEffect } from 'reactive-vscode' +import { workspace } from 'vscode' +import { config, logger } from '../meta' +import { runSync } from '../sync' +import { isValidWorkspace } from '../utils/fs' + +export function useAutoSync(): void { + const { focused, active } = useWindowState() + + const isSyncing = shallowRef(false) + const intervalMs = computed(() => Math.max(1, config.autoSync.intervalMinutes) * 60_000) + + const pendingFolders = new Set() + const timers = new Map>() + + async function doSync(cwd: string) { + if (isSyncing.value) + return + isSyncing.value = true + try { + await runSync({ cwd }) + timers.set(cwd, setTimeout(() => syncIfActive(cwd), intervalMs.value)) + } + catch (err) { + logger.error(`[auto-sync] error: `, err) + } + finally { + isSyncing.value = false + } + } + + function syncIfActive(cwd: string) { + if (focused.value && active.value) { + pendingFolders.delete(cwd) + doSync(cwd) + } + else { + pendingFolders.add(cwd) + } + } + + async function addFolder(cwd: Uri) { + if (timers.has(cwd.path)) + return + + if (await isValidWorkspace(cwd)) + return + + syncIfActive(cwd.path) + } + + function removeFolder(cwd: string) { + const timer = timers.get(cwd) + if (timer) { + clearInterval(timer) + timers.delete(cwd) + } + pendingFolders.delete(cwd) + } + + function clearAllTimers() { + for (const cwd of timers.keys()) + removeFolder(cwd) + } + + watchEffect(() => { + if (!focused.value || !active.value) + return + for (const cwd of pendingFolders) { + pendingFolders.delete(cwd) + doSync(cwd) + } + }) + + watchEffect((onCleanup) => { + if (!config.autoSync.enabled) { + clearAllTimers() + return + } + + logger.info('[auto-sync] setup') + + for (const folder of workspace.workspaceFolders ?? []) + addFolder(folder.uri) + + const disposable = workspace.onDidChangeWorkspaceFolders((e) => { + logger.info('[auto-sync] workspace folders changed') + for (const added of e.added) + addFolder(added.uri) + for (const removed of e.removed) + removeFolder(removed.uri.path) + }) + + onCleanup(() => { + disposable.dispose() + clearAllTimers() + logger.info('[auto-sync] cleanup') + }) + }) +} diff --git a/packages/vscode/src/index.ts b/packages/vscode/src/index.ts new file mode 100644 index 0000000..da4fa0f --- /dev/null +++ b/packages/vscode/src/index.ts @@ -0,0 +1,9 @@ +import { defineExtension, useCommand } from 'reactive-vscode' +import { sync } from './commands/sync' +import { useAutoSync } from './composables/auto-sync' +import { commands } from './generated-meta' + +export const { activate, deactivate } = defineExtension(() => { + useCommand(commands.sync, sync) + useAutoSync() +}) diff --git a/packages/vscode/src/meta.ts b/packages/vscode/src/meta.ts new file mode 100644 index 0000000..99e6068 --- /dev/null +++ b/packages/vscode/src/meta.ts @@ -0,0 +1,7 @@ +import type { NestedScopedConfigs } from './generated-meta' +import { defineConfig, defineLogger } from 'reactive-vscode' +import { displayName, scopedConfigs } from './generated-meta' + +export const config = defineConfig(scopedConfigs.scope) + +export const logger = defineLogger(displayName) diff --git a/packages/vscode/src/sync.ts b/packages/vscode/src/sync.ts new file mode 100644 index 0000000..f13db3f --- /dev/null +++ b/packages/vscode/src/sync.ts @@ -0,0 +1,33 @@ +import type { SyncSummary } from '../../../src/sync' +import { resolve } from 'pathe' +import { getExecuteFile, resolveConfig } from '../../../src/config/load' +import { resolveRepo } from '../../../src/config/repo' +import { ensureExecuteArtifacts } from '../../../src/execute/schema' +import { syncRepository } from '../../../src/sync' +import { readTokenFromVSCode } from './auth' +import { logger } from './meta' + +export interface RunSyncOptions { + cwd: string +} + +// See src/cli/commands/sync.ts +export async function runSync(options: RunSyncOptions): Promise { + logger.info(`[sync] run at ${Date.now()}`) + const config = await resolveConfig({ cwd: options.cwd }) + await ensureExecuteArtifacts(resolve(config.cwd, getExecuteFile(config))) + + const repo = await resolveRepo({ + cwd: config.cwd, + configRepo: config.repo, + interactive: false, + }) + + const token = config.auth.token?.trim() ?? await readTokenFromVSCode() + + return syncRepository({ + config, + repo: repo.repo, + token, + }) +} diff --git a/packages/vscode/src/utils/fs.ts b/packages/vscode/src/utils/fs.ts new file mode 100644 index 0000000..308f54b --- /dev/null +++ b/packages/vscode/src/utils/fs.ts @@ -0,0 +1,19 @@ +import { Uri, workspace } from 'vscode' +import { resolveConfig } from '../../../../src/config' + +export async function pathExist(uri: Uri) { + try { + await workspace.fs.stat(uri) + return true + } + catch { + return false + } +} + +export async function isValidWorkspace(cwd: Uri) { + const config = await resolveConfig({ cwd: cwd.path }) + const folder = Uri.joinPath(cwd, config.directory) + + return await pathExist(folder) +} diff --git a/packages/vscode/tsdown.config.ts b/packages/vscode/tsdown.config.ts new file mode 100644 index 0000000..bd8a4c7 --- /dev/null +++ b/packages/vscode/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + external: ['vscode'], + /// keep-sorted + inlineOnly: [ + 'before-after-hook', + 'bottleneck', + 'fast-content-type-parse', + 'jiti', + 'json-with-bigint', + 'pathe', + 'toad-cache', + 'universal-github-app-jwt', + 'universal-user-agent', + 'yaml', + /octokit/, + /reactive-vscode/, + ], + minify: 'dce-only', +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64dd49d..a89c51f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,21 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + packages/vscode: + devDependencies: + '@ghfs/cli': + specifier: workspace:* + version: link:../.. + '@types/vscode': + specifier: 1.101.0 + version: 1.101.0 + reactive-vscode: + specifier: ^1.0.0-beta.2 + version: 1.0.0-beta.2(@types/vscode@1.101.0) + vscode-ext-gen: + specifier: 1.6.0 + version: 1.6.0 + packages: '@antfu/eslint-config@7.6.1': @@ -582,6 +597,9 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@reactive-vscode/reactivity@1.0.0-beta.2': + resolution: {integrity: sha512-sVddfw2Fq285HMG/hGiOFyCGOMhtIBw7n0y1QG58W1CGdMBSUtJ7gXvQJuQz2qO3c9M6zY/BNYRmWCQHFbzemg==} + '@rolldown/binding-android-arm64@1.0.0-rc.3': resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -940,6 +958,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/vscode@1.101.0': + resolution: {integrity: sha512-ZWf0IWa+NGegdW3iU42AcDTFHWW7fApLdkdnBqwYEtHVIBGbTu0ZNQKP/kX3Ds/uMJXIMQNAojHR4vexCEEz5Q==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2064,6 +2085,11 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + reactive-vscode@1.0.0-beta.2: + resolution: {integrity: sha512-Er94+XaRhRuJEpSRSCuvXW9pyta53R+VEGxdIG+X/hjruT+rg2HTDZDV0RbtQjwI1yWPflDTB903MkiQOn3w5Q==} + peerDependencies: + '@types/vscode': ^1.101.0 + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -2441,6 +2467,10 @@ packages: jsdom: optional: true + vscode-ext-gen@1.6.0: + resolution: {integrity: sha512-oYg4QCx56hs1+EOVInhQMeuO4jKOCHuBrG9uppivGac8Cu9crJ7YKZg8bviDO60iX9IHqX7o5oRIV/Y+f96XwA==} + hasBin: true + vue-eslint-parser@10.4.0: resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2947,6 +2977,8 @@ snapshots: dependencies: quansync: 1.0.0 + '@reactive-vscode/reactivity@1.0.0-beta.2': {} + '@rolldown/binding-android-arm64@1.0.0-rc.3': optional: true @@ -3160,6 +3192,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/vscode@1.101.0': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4565,6 +4599,11 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + reactive-vscode@1.0.0-beta.2(@types/vscode@1.101.0): + dependencies: + '@reactive-vscode/reactivity': 1.0.0-beta.2 + '@types/vscode': 1.101.0 + readdirp@5.0.0: {} refa@0.12.1: @@ -4941,6 +4980,11 @@ snapshots: - tsx - yaml + vscode-ext-gen@1.6.0: + dependencies: + cac: 6.7.14 + tinyglobby: 0.2.15 + vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1)): dependencies: debug: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cf6c55..2c49358 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ shellEmulator: true - trustPolicy: no-downgrade +packages: + - packages/* + onlyBuiltDependencies: - esbuild - simple-git-hooks