diff --git a/docs/FUTURE.md b/docs/FUTURE.md index c1e8d2a..a830c96 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -128,3 +128,25 @@ D-008에서 Last Write Wins로 결정. 후속 개선 옵션: | `vync watch` | UI 없이 파일 감시 데몬 (자동 변환 파이프라인용) | | 업스트림 (Drawnix) 추적 | 주기적 upstream diff, 핵심 변경 최소화 | | 역변환 | PlaitElement[] → Markdown/Mermaid (기술적 난이도 높음) | + +--- + +## 6. .vync 파일 연결 UX (2026-03-12 기획) + +### 배경 +DMG 설치 시 `electron-builder.yml`의 `fileAssociations` + `UTExportedTypeDeclarations`로 .vync 파일 연결이 자동 등록됨. +개발 모드(`git clone + npm install`)에서는 CLI가 주 진입점이며, OS 파일 연결은 불필요. + +### P2: 앱 첫 실행 안내 다이얼로그 +- 패키징 앱 첫 실행 시 "Finder에서 .vync 파일 > 우클릭 > 이 앱으로 열기 > 항상" 안내 +- Electron 메뉴 Help > "How to set as default app" 도움말 항목 추가 +- 참고: `app.setAsDefaultProtocolClient()`은 프로토콜 전용이므로 파일 확장자 기본 앱 등록은 네이티브 API 필요 → 안내 다이얼로그가 현실적 + +### P3: .vync 전용 파일 아이콘 +- `.icns` (macOS) / `.ico` (Windows) 커스텀 아이콘 디자인 +- `electron-builder.yml`의 `fileAssociations.icon` 필드에 등록 +- Finder/탐색기에서 .vync 파일의 시각적 구분 + +### 완료된 P1 +- `vync open .` / `vync open` (인자 없이) — 디렉토리 내 .vync 파일 자동 발견 +- UTI 등록 (`com.vync.canvas`) — `electron-builder.yml`의 `mac.extendInfo.UTExportedTypeDeclarations` diff --git a/electron-builder.yml b/electron-builder.yml index cd7e318..29bf19c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,6 +13,16 @@ mac: entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.inherit.plist notarize: true + extendInfo: + UTExportedTypeDeclarations: + - UTTypeIdentifier: com.vync.canvas + UTTypeDescription: Vync Canvas + UTTypeConformsTo: + - public.json + - public.data + UTTypeTagSpecification: + public.filename-extension: + - vync dmg: title: Vync @@ -29,6 +39,7 @@ fileAssociations: - ext: vync name: Vync Canvas role: Editor + mimeType: application/vync files: - dist/electron/**/* diff --git a/tools/cli/__tests__/discover.test.ts b/tools/cli/__tests__/discover.test.ts new file mode 100644 index 0000000..fb6ccc8 --- /dev/null +++ b/tools/cli/__tests__/discover.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const tmpBase = path.join(os.tmpdir(), `vync-discover-${Date.now()}`); + +describe('discoverVyncFiles', () => { + beforeEach(async () => { + await fs.mkdir(tmpBase, { recursive: true }); + process.env.VYNC_CALLER_CWD = tmpBase; + }); + + afterEach(async () => { + delete process.env.VYNC_CALLER_CWD; + await fs.rm(tmpBase, { recursive: true, force: true }).catch(() => {}); + }); + + async function loadDiscover() { + // Fresh import to pick up env changes + const mod = await import('../discover.js'); + return mod.discoverVyncFiles; + } + + const STUB = JSON.stringify({ + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [], + }); + + it('finds .vync files in CWD', async () => { + await fs.writeFile(path.join(tmpBase, 'a.vync'), STUB); + await fs.writeFile(path.join(tmpBase, 'b.vync'), STUB); + await fs.writeFile(path.join(tmpBase, 'readme.md'), 'hello'); + + const discover = await loadDiscover(); + const files = await discover(); + + expect(files).toHaveLength(2); + expect(files.every((f: string) => f.endsWith('.vync'))).toBe(true); + }); + + it('finds .vync files in CWD/.vync/ subdirectory', async () => { + const subdir = path.join(tmpBase, '.vync'); + await fs.mkdir(subdir); + await fs.writeFile(path.join(subdir, 'plan.vync'), STUB); + + const discover = await loadDiscover(); + const files = await discover(); + + expect(files).toHaveLength(1); + expect(files[0]).toContain('.vync/plan.vync'); + }); + + it('combines CWD and .vync/ results', async () => { + await fs.writeFile(path.join(tmpBase, 'root.vync'), STUB); + const subdir = path.join(tmpBase, '.vync'); + await fs.mkdir(subdir); + await fs.writeFile(path.join(subdir, 'sub.vync'), STUB); + + const discover = await loadDiscover(); + const files = await discover(); + + expect(files).toHaveLength(2); + }); + + it('returns empty array when no .vync files exist', async () => { + await fs.writeFile(path.join(tmpBase, 'readme.md'), 'hello'); + + const discover = await loadDiscover(); + const files = await discover(); + + expect(files).toEqual([]); + }); + + it('returns sorted results', async () => { + await fs.writeFile(path.join(tmpBase, 'z.vync'), STUB); + await fs.writeFile(path.join(tmpBase, 'a.vync'), STUB); + await fs.writeFile(path.join(tmpBase, 'm.vync'), STUB); + + const discover = await loadDiscover(); + const files = await discover(); + + const names = files.map((f: string) => path.basename(f)); + expect(names).toEqual(['a.vync', 'm.vync', 'z.vync']); + }); + + it('ignores node_modules and .git directories', async () => { + const nm = path.join(tmpBase, 'node_modules', 'pkg'); + await fs.mkdir(nm, { recursive: true }); + await fs.writeFile(path.join(nm, 'bad.vync'), STUB); + + const git = path.join(tmpBase, '.git'); + await fs.mkdir(git, { recursive: true }); + await fs.writeFile(path.join(git, 'bad.vync'), STUB); + + await fs.writeFile(path.join(tmpBase, 'good.vync'), STUB); + + const discover = await loadDiscover(); + const files = await discover(); + + expect(files).toHaveLength(1); + expect(files[0]).toContain('good.vync'); + }); +}); diff --git a/tools/cli/discover.ts b/tools/cli/discover.ts new file mode 100644 index 0000000..3f66ba3 --- /dev/null +++ b/tools/cli/discover.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** Caller's actual CWD (passed as VYNC_CALLER_CWD by bin/vync.js) */ +function getCallerCwd(): string { + return process.env.VYNC_CALLER_CWD || process.cwd(); +} + +const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); + +/** + * Discover .vync files in the caller's CWD. + * Scans: CWD/*.vync + CWD/.vync/*.vync (1 level only, no deep recursion). + * Returns sorted absolute paths. + */ +export async function discoverVyncFiles(): Promise { + const cwd = getCallerCwd(); + const found: string[] = []; + + // Scan CWD for *.vync files + try { + const entries = await fs.readdir(cwd, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.vync')) { + found.push(path.resolve(cwd, entry.name)); + } + } + } catch { + // CWD not readable + } + + // Scan CWD/.vync/ subdirectory + const vyncSubdir = path.join(cwd, '.vync'); + try { + const entries = await fs.readdir(vyncSubdir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.vync')) { + found.push(path.resolve(vyncSubdir, entry.name)); + } + } + } catch { + // .vync/ doesn't exist or not readable + } + + // Scan immediate subdirectories (1 level, skip ignored) + try { + const entries = await fs.readdir(cwd, { withFileTypes: true }); + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name === '.vync' || + entry.name.startsWith('.') || + IGNORED_DIRS.has(entry.name) + ) { + continue; + } + const subdir = path.join(cwd, entry.name); + try { + const subEntries = await fs.readdir(subdir, { withFileTypes: true }); + for (const sub of subEntries) { + if (sub.isFile() && sub.name.endsWith('.vync')) { + found.push(path.resolve(subdir, sub.name)); + } + } + } catch { + // subdirectory not readable + } + } + } catch { + // CWD not readable + } + + // Deduplicate and sort + return [...new Set(found)].sort(); +} diff --git a/tools/cli/main.ts b/tools/cli/main.ts index aa40c3b..cc59471 100644 --- a/tools/cli/main.ts +++ b/tools/cli/main.ts @@ -1,12 +1,14 @@ import { vyncInit } from './init.js'; import { vyncOpen, vyncStop, vyncClose } from './open.js'; import { vyncDiff, formatDiffResult } from './diff.js'; +import { discoverVyncFiles } from './discover.js'; const USAGE = `Usage: vync [options] Commands: init Create .vync canvas in CWD/.vync/ - open Start server and open browser + open [file|.] Start server and open browser + No args or "." discovers .vync files in CWD --foreground Run in foreground (blocking) close [file] Unregister file (or all files if no file given) --keep-server Keep server running even if no files left @@ -17,6 +19,8 @@ Commands: Examples: vync init plan # creates .vync/plan.vync vync open plan # opens .vync/plan.vync + vync open # discovers .vync files in CWD + vync open . # same as above vync close plan # unregisters plan.vync (stops server if last file) vync close # unregisters all files and stops server vync diff plan # shows changes since last read @@ -44,11 +48,50 @@ async function main() { case 'open': { const foreground = args.includes('--foreground'); const filePath = args.find((a) => !a.startsWith('--')); - if (!filePath) { - console.error('Usage: vync open [--foreground]'); - process.exit(1); + if (!filePath || filePath === '.') { + // Discover .vync files in CWD + const discovered = await discoverVyncFiles(); + if (discovered.length === 0) { + console.error('[vync] No .vync files found in current directory.'); + console.error('[vync] Run "vync init " to create one.'); + process.exit(1); + } + let chosen: string; + if (discovered.length === 1) { + chosen = discovered[0]; + console.log(`[vync] Found: ${chosen}`); + } else { + console.log('[vync] Found .vync files:'); + discovered.forEach((f, i) => console.log(` ${i + 1}. ${f}`)); + const rl = await import('node:readline'); + const iface = rl.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await new Promise((resolve) => { + iface.question( + `Select file (1-${discovered.length}), or "a" for all: `, + resolve + ); + }); + iface.close(); + if (answer.toLowerCase() === 'a') { + for (const f of discovered) { + await vyncOpen(f, { foreground: false }); + } + break; + } + const idx = parseInt(answer, 10) - 1; + if (isNaN(idx) || idx < 0 || idx >= discovered.length) { + console.error('[vync] Invalid selection.'); + process.exit(1); + } + chosen = discovered[idx]; + } + await vyncOpen(chosen, { foreground }); + } else { + await vyncOpen(filePath, { foreground }); } - await vyncOpen(filePath, { foreground }); break; } case 'close': {