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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ server.log

# Worktrees
.worktrees/

# Docs — archived session/design records
docs/archive/
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ npm run build:web # Build web app
npm run package:desktop # Package macOS DMG
```

## Electron Bundle Sync (필수)

`tools/server/`, `tools/electron/`, `packages/shared/` 파일을 수정한 경우, **반드시** Electron 번들을 리빌드해야 한다:

```bash
npx esbuild tools/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external --alias:@vync/shared=./packages/shared/src/index.ts --sourcemap
```

- **왜**: Electron dev 모드(`npm run dev:desktop`)는 `dist/electron/main.js` 번들을 실행함. 소스만 수정하고 번들을 안 빌드하면 변경이 반영되지 않음
- **언제**: 위 디렉토리 파일 수정 후, 커밋 전에 반드시 실행
- **DMG 리패키징**: `npm run package:desktop` — 유저가 명시적으로 요청할 때만 실행 (2-3분 소요, 코드 서명+공증 포함)

## Claude Code Plugin

**Install**: `npm install` (postinstall → marketplace 등록 + 캐시 동기화)
Expand Down Expand Up @@ -119,4 +131,4 @@ feat/xyz ●──● ●──● (short-lived feature branches)
- Frontend: onChange → 300ms debounce → PUT /api/sync?file=<path>
- WebSocket: file-scoped broadcast (A.vync changes only reach A.vync clients)
- Hub WS: no `?file=` param → receives file registration/unregistration events for multi-tab UI
- Multi-tab UI: TabBar component, active tab only mounted, `+` dropdown for reopening closed tabs
- Multi-tab UI: TabBar component, active tab only mounted, `+` dropdown with "Reopen" (closed tabs) and "Open" (discovered unregistered `.vync` files via `GET /api/files/discover`)
36 changes: 36 additions & 0 deletions apps/web/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function App() {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const [registeredFiles, setRegisteredFiles] = useState<string[]>([]);
const [discoveredFiles, setDiscoveredFiles] = useState<string[]>([]);
const tabsRef = useRef(tabs);
tabsRef.current = tabs;

Expand Down Expand Up @@ -126,15 +127,50 @@ export function App() {
setActiveFilePath(filePath);
}, []);

const handleDropdownOpen = useCallback(async () => {
try {
const res = await fetch('/api/files/discover');
if (res.ok) {
const data = await res.json();
setDiscoveredFiles(data.files || []);
}
} catch {
setDiscoveredFiles([]);
}
}, []);

const handleDiscoverFile = useCallback(
async (filePath: string) => {
try {
const res = await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath }),
});
if (res.ok) {
const data = await res.json();
handleAddFile(data.filePath);
setDiscoveredFiles([]);
}
} catch (err) {
console.error('[vync] Failed to register discovered file:', err);
}
},
[handleAddFile]
);

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TabBar
tabs={tabsWithLabels}
activeFilePath={activeFilePath}
registeredFiles={registeredFiles}
discoveredFiles={discoveredFiles}
onTabClick={setActiveFilePath}
onTabClose={handleTabClose}
onAddFile={handleAddFile}
onDiscoverFile={handleDiscoverFile}
onDropdownOpen={handleDropdownOpen}
/>
{activeFilePath ? (
<div style={{ flex: 1, overflow: 'hidden' }}>
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/app/tab-bar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
height: 36px;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
overflow-x: auto;
flex-shrink: 0;
}

.vync-tab-scroll {
display: flex;
align-items: stretch;
min-width: 0;
overflow-x: auto;
&::-webkit-scrollbar { height: 0; }
}

Expand Down Expand Up @@ -70,6 +75,21 @@
min-width: 180px;
z-index: 100;
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
}

.vync-tab-dropdown__section {
padding: 4px 12px;
font-size: 11px;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
&:not(:first-child) {
border-top: 1px solid #eee;
margin-top: 2px;
padding-top: 6px;
}
}

.vync-tab-dropdown__item {
Expand Down
129 changes: 88 additions & 41 deletions apps/web/src/app/tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@ interface TabBarProps {
tabs: TabInfo[];
activeFilePath: string | null;
registeredFiles: string[];
discoveredFiles: string[];
onTabClick: (filePath: string) => void;
onTabClose: (filePath: string) => void;
onAddFile: (filePath: string) => void;
onDiscoverFile: (filePath: string) => void;
onDropdownOpen: () => void;
}

export function TabBar({
tabs,
activeFilePath,
registeredFiles,
discoveredFiles,
onTabClick,
onTabClose,
onAddFile,
onDiscoverFile,
onDropdownOpen,
}: TabBarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
Expand All @@ -26,7 +32,10 @@ export function TabBar({
useEffect(() => {
if (!dropdownOpen) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
};
Expand All @@ -40,52 +49,90 @@ export function TabBar({

return (
<div className="vync-tab-bar">
{tabs.map((tab) => (
<div
key={tab.filePath}
className={`vync-tab ${activeFilePath === tab.filePath ? 'vync-tab--active' : ''}`}
title={tab.filePath}
onClick={() => onTabClick(tab.filePath)}
>
<span>{tab.label}</span>
<button
className="vync-tab__close"
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.filePath);
}}
<div className="vync-tab-scroll">
{tabs.map((tab) => (
<div
key={tab.filePath}
className={`vync-tab ${
activeFilePath === tab.filePath ? 'vync-tab--active' : ''
}`}
title={tab.filePath}
onClick={() => onTabClick(tab.filePath)}
>
×
</button>
</div>
))}
<span>{tab.label}</span>
<button
className="vync-tab__close"
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.filePath);
}}
>
×
</button>
</div>
))}
</div>
<div className="vync-tab-add" ref={dropdownRef}>
<span onClick={() => setDropdownOpen(!dropdownOpen)}>+</span>
<span
onClick={() => {
const willOpen = !dropdownOpen;
setDropdownOpen(willOpen);
if (willOpen) onDropdownOpen();
}}
>
+
</span>
{dropdownOpen && (
<div className="vync-tab-dropdown">
{unopenedFiles.length > 0 ? (
unopenedFiles.map((fp) => {
const parts = fp.split('/');
const label = parts[parts.length - 1] || fp;
return (
<div
key={fp}
className="vync-tab-dropdown__item"
title={fp}
onClick={() => {
onAddFile(fp);
setDropdownOpen(false);
}}
>
{label}
</div>
);
})
) : (
{unopenedFiles.length > 0 && (
<>
<div className="vync-tab-dropdown__section">Reopen</div>
{unopenedFiles.map((fp) => {
const parts = fp.split('/');
const label = parts[parts.length - 1] || fp;
return (
<div
key={fp}
className="vync-tab-dropdown__item"
title={fp}
onClick={() => {
onAddFile(fp);
setDropdownOpen(false);
}}
>
{label}
</div>
);
})}
</>
)}
{discoveredFiles.length > 0 && (
<>
<div className="vync-tab-dropdown__section">Open</div>
{discoveredFiles.map((fp) => {
const parts = fp.split('/');
const label = parts[parts.length - 1] || fp;
return (
<div
key={fp}
className="vync-tab-dropdown__item"
title={fp}
onClick={() => {
onDiscoverFile(fp);
setDropdownOpen(false);
}}
>
{label}
</div>
);
})}
</>
)}
{unopenedFiles.length === 0 && discoveredFiles.length === 0 && (
<div className="vync-tab-dropdown__empty">
No more files.
No files found.
<br />
Use <code>vync open</code> to register new files.
Use <code>vync open &lt;file&gt;</code> to add files.
</div>
)}
</div>
Expand Down
8 changes: 4 additions & 4 deletions commands/vync.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ node "$VYNC_HOME/bin/vync.js" <subcommand> [args]
- `read [file]` — diff 실행 → 의미적 번역
- `update <instruction>` — diff 확인 → 맥락+지시 기반 .vync 수정 + 서버 열기

For these, use the Agent tool with `subagent_type: "vync-translator"`.
For these, use the Agent tool with `subagent_type: "vync:vync-translator"`.

## Sub-agent 호출 절차

Expand All @@ -48,7 +48,7 @@ For these, use the Agent tool with `subagent_type: "vync-translator"`.
```
Agent({
description: "Vync create visualization",
subagent_type: "vync-translator",
subagent_type: "vync:vync-translator",
mode: "bypassPermissions",
prompt: "## 작업: Create\n파일: <absolute_path>\n\n## 대화 맥락\n<메인 세션이 요약한 현재 논의 상황, 2-5문장>\n\n## 지시\n<구체적 지시 or '맥락에 맞게 판단해서 시각화해줘'>\n<선호하는 유형이 있으면: 'mindmap 형식으로' 등>"
})
Expand All @@ -66,7 +66,7 @@ Agent({
```
Agent({
description: "Vync read + translate diff",
subagent_type: "vync-translator",
subagent_type: "vync:vync-translator",
mode: "bypassPermissions",
prompt: "## 작업: Read\n파일: <absolute_path>\n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n<diff 결과>\n\n## 지시\n위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘."
})
Expand All @@ -84,7 +84,7 @@ Agent({
```
Agent({
description: "Vync update visualization",
subagent_type: "vync-translator",
subagent_type: "vync:vync-translator",
mode: "bypassPermissions",
prompt: "## 작업: Update\n파일: <absolute_path>\n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n<diff 결과 or '없음'>\n\n## 지시\n<구체적 수정 지시>"
})
Expand Down
Loading
Loading