Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/FUTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
11 changes: 11 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +39,7 @@ fileAssociations:
- ext: vync
name: Vync Canvas
role: Editor
mimeType: application/vync

files:
- dist/electron/**/*
Expand Down
105 changes: 105 additions & 0 deletions tools/cli/__tests__/discover.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
75 changes: 75 additions & 0 deletions tools/cli/discover.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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();
}
53 changes: 48 additions & 5 deletions tools/cli/main.ts
Original file line number Diff line number Diff line change
@@ -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 <command> [options]

Commands:
init <file> Create .vync canvas in CWD/.vync/
open <file> 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
Expand All @@ -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
Expand Down Expand Up @@ -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 <file> [--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 <name>" 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<string>((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': {
Expand Down
Loading