diff --git a/CLAUDE.md b/CLAUDE.md index ef907f5..e734c04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Product Contract -Tasteful Intent is a greenfield Tauri desktop Markdown editor for human-authored intentions. The selected `libraryRoot` remains canonical Intent source data. AI keeps one ordered list of user-selected canonical folder tabs with one root-local session per tab; pinning is a tab property represented by an optional 1-2 grapheme label, not a separate source mode. AI edits content through the shared Edit, View, and Split modes and the existing atomic-save/mtime-conflict boundary; AI can create a new Markdown document in the selected folder from the document-list header, and the new document opens in Edit; AI folder creation, rename, move, and Trash remain unavailable. AI View adds a read-only git-diff view cycle (changes-only with collapsed context → full document with highlighted changes → off) that compares the active document body against its git HEAD baseline; it never runs mutating git commands and hides itself when git or a repository is unavailable. Do not add migration adapters for the former PromptPad product. LLM runtime, tags, toolbar, and wiki features are outside v0.2. +Tasteful Intent is a greenfield Tauri desktop Markdown editor for human-authored intentions. The selected `libraryRoot` remains canonical Intent source data. AI keeps one ordered list of user-selected canonical folder tabs with one root-local session per tab; pinning is a tab property represented by an optional 1-2 grapheme label, not a separate source mode. AI edits content through the shared Edit, View, and Split modes and the existing atomic-save/mtime-conflict boundary; AI can create a new Markdown document in the selected folder from the document-list header, and the new document opens in Edit; AI folder creation, rename, move, and Trash remain unavailable. AI View adds a read-only git-diff view cycle (changes-only with collapsed context → full document with highlighted changes → off) that compares the active document body against its git HEAD baseline; it never runs mutating git commands and hides itself when git or a repository is unavailable. When the active AI root is inside a git work tree, the AI document list adds a read-only git status filter (`All → Modified+New → Modified → New → All`) classified against HEAD like the diff view; it is hidden when git or a repository is unavailable. Do not add migration adapters for the former PromptPad product. LLM runtime, tags, toolbar, and wiki features are outside v0.2. Read `docs/specs/intent-memo.md` and `DESIGN.md` before changing product behavior or UI. @@ -48,10 +48,10 @@ The filesystem is the database. Filenames are document titles. Frontmatter conta - An icon-only pane button immediately before the tabs cycles three panes, two panes, and content-only. The folder pane alone owns the active root display and folder picker; `⌘1` remains a keyboard return path but is not shown as a badge on the switcher. - The folder tree root and root move destination use the selected directory basename; never present a hardcoded `Library` default. - Human keeps its fixed-height 78px two-row Source Card below the space switcher. AI uses a compact pinned-shortcut header followed by a vertically scrollable list of every open root. Each visible path keeps only its final two segments as `…/parent/leaf`; pinned list rows prefix the path with their label, while the header repeats only pinned labels as shortcuts. Every path row exposes a direct `Pin` toggle: unpinned is muted but enabled, pinned uses the current AI accent; pinning opens the existing label input before activation and clicking an active pin immediately unpins. The ellipsis menu contains only `Edit label` for pinned roots or `Close` for unpinned roots, plus targeted `Refresh` when unavailable. Pin moves a tab to the end of the pinned group, Unpin moves it to the front of the unpinned group without removing its session, and Edit label preserves root/session identity. Only unpinned tabs can close, removing that root-local session without touching disk files. Exact duplicate roots activate the existing tab; arbitrary visible non-symlink canonical roots, including ancestor/descendant pairs, are allowed. Root activation and close use the save barrier, while pin, unpin, and label edits only persist list transformations. The two-pane navigation fallback retains the AI controls; content-only does not. -- AI uses root-local one-line document tabs without a source badge; tooltip and accessible name retain the canonical full path. The AI rendered view opens with a muted fixed-font `…/parent/file.md` document-path line above the body that scrolls with the content, keeps the canonical full path as tooltip, and ends with the document's on-disk modified time so reloads make file updates visible; Human mode and PDF export omit it. The AI Explorer mixes folders and Markdown files under the selected root; folder activation selects and expands/collapses the branch, while file activation opens a tab and selects its parent folder. Restoring an active AI session applies the same parent-folder selection. The middle Document List shows only direct Markdown children of the selected folder, marks the active document selected, and keeps that row visible after document, snippet, density, or pane-size changes without moving focus. Folder selection, expanded-tree state, and root availability are runtime-only per root. A missing root keeps its folder tab, optional label, and saved session; targeted Refresh activates it only after a successful scan. +- AI uses root-local one-line document tabs without a source badge; tooltip and accessible name retain the canonical full path. The AI rendered view opens with a muted fixed-font `…/parent/file.md` document-path line above the body that scrolls with the content, keeps the canonical full path as tooltip, and ends with the document's on-disk modified time so reloads make file updates visible; Human mode and PDF export omit it. The AI Explorer mixes folders and Markdown files under the selected root; folder activation selects and expands/collapses the branch, while file activation opens a tab and selects its parent folder. Restoring an active AI session applies the same parent-folder selection. The middle Document List shows only direct Markdown children of the selected folder, marks the active document selected, and keeps that row visible after document, snippet, density, or pane-size changes without moving focus. While the git status filter is `Modified+New`, `Modified` (differs from HEAD), or `New` (path missing from HEAD), the Explorer keeps only matching files and the folders above them, fully expanded, and the Document List shows matches anywhere below the selected folder with a muted relative parent path; a selected folder the filter hides reads as the root, and returning to `All` restores the pre-filter selection and expansion. Git status is read with each AI scan (Refresh, root activation, post-mutation rescan) and when leaving `All`, never after saves; `All` only checks for a work tree. Folder selection, expanded-tree state, git filter, and root availability are runtime-only per root. A missing root keeps its folder tab, optional label, and saved session; targeted Refresh activates it only after a successful scan. - `⌘1` toggles folders while the list is visible; `⌘2` toggles list plus folders. - `⌘F` and `Ctrl+F` open a non-modal current-document Find overlay when an active document exists. Search is case-insensitive literal matching over the current Markdown body only; Enter/Shift+Enter and the next/previous buttons wrap through results, while Escape closes the overlay and restores the prior focus. Human/AI Edit/View/Split reflect the same active result without persisting search state or scanning the workspace. -- The document list has a global `documentDensity` shared by Human and AI and persisted in `settings.json`: Full shows title, up to two snippet lines, and updated date; Medium shows title and one snippet line; Simple shows title only. Its header cycles `Full → Medium → Simple → Full` with `Rows4`/`Rows3`/`Rows2` between the latest/title sort toggle and create. Human uses `Refresh → Sort → Density → Create`; AI uses the same `Refresh → Sort → Density → Create` order regardless of pin state; its Create button reads `New document`, creates in the selected folder through the shared NameDialog, opens the new document in Edit, and is disabled while the active AI root is unavailable. Opening another AI folder stays in the AI Source Card `FolderPlus` button and the empty content state. Folder rows have no numeric counts. The global `documentSort` preference remains shared by Human and AI and persisted. +- The document list has a global `documentDensity` shared by Human and AI and persisted in `settings.json`: Full shows title, up to two snippet lines, and updated date; Medium shows title and one snippet line; Simple shows title only. Its header cycles `Full → Medium → Simple → Full` with `Rows4`/`Rows3`/`Rows2` between the latest/title sort toggle and create. Human uses `Refresh → Sort → Density → Create`; AI uses the same order regardless of pin state, with the git status filter inserted as `Refresh → Filter → Sort → Density → Create` when the root is inside a git work tree; its Create button reads `New document`, creates in the selected folder through the shared NameDialog, opens the new document in Edit, and is disabled while the active AI root is unavailable. Opening another AI folder stays in the AI Source Card `FolderPlus` button and the empty content state. Folder rows have no numeric counts. The global `documentSort` preference remains shared by Human and AI and persisted. - Settings has separate Appearance, Typography, and Language navigation. Appearance offers Light (default), Two-Tone, Dark, and System themes plus Classic, Terracotta & Teal, Plum & Moss, and Mono Duo Human/AI color palettes in independent 2×2 radio-tile groups; both choices apply immediately and persist independently. The persisted Two-Tone key remains `charcoal`, System follows the OS color mode, and the default palette key is `classic`. Typography offers Sans-serif (default) and Serif in 2-column cards with a live preview; it applies only to Markdown editing/reading and large empty-state copy while application chrome remains Sans-serif. Language offers English (default) and 한국어 in the same 2-column card pattern with a localized live preview, applies immediately across application chrome, and persists in `settings.json`. User filenames, folder names, Markdown titles, and Markdown bodies are never translated. The visible Settings button lives at the navigation bottom: folder pane in three-pane mode, document-list pane in two-pane mode, and nowhere in content-only mode. Settings has no keyboard shortcut. - Reuse tokens and primitives from `DESIGN.md`. The development showcase is `?showcase=1`. diff --git a/DESIGN.md b/DESIGN.md index 80ad8c3..de46123 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -113,7 +113,7 @@ Tasteful Intent는 조용한 종이 책상처럼 느껴져야 한다. 크롬은 ### PaneHeader - label, 현재 경로 또는 mode, 필요한 icon button 최대 4개. -- document list header action은 Human에서 `Refresh → Sort → Density → Create`, AI에서도 pin 여부와 무관하게 `Refresh → Sort → Density → Create` 순서다. AI Create는 `새 문서`로 표시하고 선택 folder에 Markdown을 만든 뒤 Edit로 열며, active AI root가 unavailable이면 비활성화한다. AI 폴더 열기는 AI Source Card의 `FolderPlus` button과 빈 content 상태가 담당한다. 정렬과 밀도 icon의 accessible copy는 현재 상태와 click 후 결과를 함께 설명한다. +- document list header action은 Human에서 `Refresh → Sort → Density → Create`, AI에서도 pin 여부와 무관하게 `Refresh → Sort → Density → Create` 순서다. AI root가 git work tree 안이면 Refresh 뒤에 git status filter cycle button을 넣어 `Refresh → Filter → Sort → Density → Create`가 된다. 순환 순서는 `All → Modified+New → Modified → New → All`이다. filter icon은 `All` `ListFilter`, `Modified+New` `FilePenLine`+`FilePlusCorner` 두 개(button 폭 44px), `Modified` `FilePenLine`, `New` `FilePlusCorner`이고, `All`이 아니면 `--space-tint`/`--space-text`로 강조하며 accessible copy와 tooltip은 현재 상태와 click 후 결과를 함께 설명한다. AI Create는 `새 문서`로 표시하고 선택 folder에 Markdown을 만든 뒤 Edit로 열며, active AI root가 unavailable이면 비활성화한다. AI 폴더 열기는 AI Source Card의 `FolderPlus` button과 빈 content 상태가 담당한다. 정렬과 밀도 icon의 accessible copy는 현재 상태와 click 후 결과를 함께 설명한다. - hover에만 보이는 동작도 keyboard focus에서는 항상 보여야 한다. ### FolderTreeItem @@ -129,6 +129,7 @@ Tasteful Intent는 조용한 종이 책상처럼 느껴져야 한다. 크롬은 - unpinned root row는 folder basename, pinned root row는 `[label] folder basename`을 표시한다. tooltip과 accessible name에는 canonical 전체 root를 유지한다. - root 아래 folder와 Markdown file을 이름순으로 섞어 표시한다. folder row click은 해당 folder를 선택하고 branch를 inline expand/collapse하며, file row click은 문서를 tab에 열고 parent folder를 선택한다. active AI session 복원도 같은 parent-folder selection을 적용한다. - selected folder는 기존 `--space-tint`/`--space-text`, active file은 동일 token과 file icon을 사용한다. depth는 14px 단위이고 chevron, folder/file icon, label 순서를 유지한다. +- git status filter가 `All`이 아니면 해당 문서와 그 상위 folder만 남기고 모두 펼친 상태로 시작한다. 이때 Document List row는 제목 아래에 선택 folder 기준 상위 경로를 `--muted`·`--fixed-font`·`--type-xs` 한 줄로 표시한다. - hidden/ignored/symlink entry와 Markdown이 없는 branch는 native snapshot에 포함하지 않는다. 가운데 Document List는 selected folder의 direct Markdown children만 표시하고 active file row를 selected로 표시한다. 선택 row가 document·snippet·density·pane-size 변화 뒤 viewport 밖이면 nearest 위치로 scroll하되 이미 보이는 row와 DOM focus는 유지한다. - keyboard focus-visible, `aria-expanded`, active file `aria-current`, full-path tooltip을 제공하고 216px pane에서 horizontal page scroll을 만들지 않는다. @@ -192,7 +193,7 @@ Tasteful Intent는 조용한 종이 책상처럼 느껴져야 한다. 크롬은 - 새 canonical root는 tab으로 추가하고 exact root 재선택은 기존 tab을 활성화한다. ancestor/descendant 관계는 허용한다. root 전환은 모든 열린 문서 save와 target scan 성공 후에만 settings와 mounted workspace를 변경한다. - missing root는 tab, optional label, root-local session을 유지한다. 선택 실패는 현재 workspace를 유지하고 대상만 unavailable로 표시하며, targeted Refresh 성공 후에만 활성화한다. - root close는 target을 다시 scan하지 않는다. active unpinned tab을 닫으면 오른쪽 우선, 없으면 왼쪽 root를 선택하고 해당 session만 제거한다. Pin, Unpin, Edit label은 mounted workspace를 바꾸지 않아 save barrier가 필요 없다. -- settings는 `settingsSchemaVersion: 2`, `docsRoots`, `docsRoot`, root-indexed `tabSessions.docs`를 사용한다. 저장 성공 후에만 React state와 mounted workspace를 변경하며 Explorer navigation과 availability는 root별 runtime-only map이다. +- settings는 `settingsSchemaVersion: 2`, `docsRoots`, `docsRoot`, root-indexed `tabSessions.docs`를 사용한다. 저장 성공 후에만 React state와 mounted workspace를 변경하며 Explorer navigation, git status filter, availability는 root별 runtime-only map이다. - AI는 in-app folder picker만 제공하고 외부 drag/drop은 이 slice에 포함하지 않는다. tooltip과 accessible name에는 canonical 전체 root/path를 유지한다. - folder pane이 숨겨진 2-pane fallback은 동일 AI Source Card를 문서 목록 pane에 한 번만 표시하고 content-only에는 반복하지 않는다. diff --git a/README.ko.md b/README.ko.md index a9e0d7d..82e134f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -52,6 +52,7 @@ Apple silicon과 Intel용 DMG는 [Releases 페이지](https://github.com/tkhwang - `⌘F` 또는 `Ctrl+F`로 현재 문서를 검색하고 Edit, View, Split에서 결과를 강조합니다. - root-relative 로컬 이미지를 10 MiB까지 표시하고 현재 문서의 rendering 결과를 PDF로 내보냅니다. - 최신순과 제목순으로 정렬하고 문서 목록 밀도를 Full, Medium, Simple로 전환합니다. +- git repository 안의 AI 폴더에서는 마지막 commit 이후 수정되었거나 새로 생긴 문서만 걸러 보고, View에서 열린 문서를 마지막 commit과 비교합니다. - Keyboard로 접근 가능한 context menu에서 Human 파일과 폴더를 만들고, 이름을 바꾸고, 이동하고, 시스템 휴지통으로 보냅니다. - 3-pane, 2-pane, content-only mode를 사용합니다. 영어·한국어 UI와 테마 4개, Human·AI 색상 조합 4개, 글꼴 2개를 제공합니다. diff --git a/README.md b/README.md index caa7337..0bc4fc9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Apple silicon and Intel DMGs are also available on the [Releases page](https://g - Find text in the current document with `⌘F` or `Ctrl+F`, including highlighted matches in Edit, View, and Split. - Render root-relative local images up to 10 MiB and export the current rendered document as a PDF. - Sort by latest update or title and switch the document list between Full, Medium, and Simple density. +- In AI folders inside a git repository, filter the list to documents modified or new since the last commit, and compare the open document with the last commit in View. - Create, rename, move, and send Human files or folders to the system Trash through keyboard-accessible context menus. - Work in three panes, two panes, or content-only mode. The app includes English and Korean UI, four themes, four Human/AI color palettes, and two writing typefaces. diff --git a/docs/plans/2026-10-14-ai-git-status-filter.md b/docs/plans/2026-10-14-ai-git-status-filter.md new file mode 100644 index 0000000..3144848 --- /dev/null +++ b/docs/plans/2026-10-14-ai-git-status-filter.md @@ -0,0 +1,75 @@ +# AI Git Status Filter + +## Goal + +AI root에 base directory를 지정하면 지금은 하위 Markdown 전체가 Explorer와 Document List에 나온다. AI가 문서를 많이 고친 뒤에는 무엇이 바뀌었는지 찾기 어렵다. git status를 기준으로 `All / Modified / New` 필터를 제공해, 변경된 문서만 골라 보고 기존 View diff로 바로 확인할 수 있게 한다. + +## 현재 상태 + +- `scan_docs_root`(`src-tauri/src/library.rs:141`)는 `.gitignore`/`.ignore`를 존중해 Markdown만 scan하고, folders는 문서 경로에서 파생한다(`library.rs:170-178`). gitignored 파일은 이미 목록에 없다. +- `git_read_only()`(`library.rs:241`)는 `-C root -c core.fsmonitor=false --no-optional-locks`로 read-only git을 실행한다. `git_head_baseline()`은 macOS CLT 미설치 시 GUI prompt를 막는 `xcode-select -p` guard를 먼저 수행한다. +- View diff는 HEAD 기준이다. `git show HEAD:./path`가 실패하면 `untracked`로 취급한다(`library.rs:285`). 따라서 staged 신규(`A`)와 rename 대상 경로도 diff에서는 untracked다. +- `git status --porcelain=v1`의 경로는 subdirectory에서 실행해도 repo root 기준이다. AI root 기준 경로를 얻으려면 `git rev-parse --show-prefix`를 제거해야 한다(2026-10-14 git 2.56.0으로 확인). +- 모든 Tauri command는 sync `pub fn`이다. Tauri 2에서 sync command는 main thread에서 실행된다. +- scan은 Refresh 버튼, root 활성화, mutation 후에만 다시 실행된다. window focus나 file watch 기반 자동 갱신은 없다. +- Document List는 선택 folder의 direct Markdown children만 보여준다(`useLibraryWorkspace.ts:291-296`). folder 선택과 펼침 상태는 root별 runtime-only다(`App.tsx:596-602`, `:1090-1095`). +- AI folder pane header는 `Folders` 제목만 있고 오른쪽 action 자리가 비어 있다(`App.tsx:1371-1381`). + +## Behavior + +1. 별도 git mode 토글은 없다. active AI root가 git work tree 안에 있으면 필터 컨트롤을 표시하고, git이나 repository를 사용할 수 없으면 숨긴다(diff toggle과 같은 원칙). 기본값 `All`은 현재 동작과 같고, "git mode"는 필터가 `All`이 아닌 상태를 뜻한다. `All`에서는 work-tree 판별만 하고 status 전체는 읽지 않는다. +2. 필터는 `All | Modified+New | Modified | New`이고 HEAD를 기준으로 나눈다. View diff와 같은 기준이다. `Modified+New`는 아래 두 bucket의 합집합이다. + - Modified: HEAD에 같은 경로가 있고 내용이 다른 문서. staged/unstaged를 구분하지 않는다(` M`, `M `, `MM`, `T`). + - New: HEAD에 그 경로가 없는 문서(`??`, staged `A`, rename 대상 경로 `R`). + - 디스크에 없는 삭제 문서(`D`)는 열 수 없으므로 어느 bucket에도 넣지 않는다. + - root 안의 nested repo는 git status에 `?? dir/` 한 줄로 나타난다. 같은 HEAD 기준으로 그 아래 문서는 `New`이며, View diff도 전체 추가로 표시하므로 일치한다. +3. 필터는 Explorer tree와 Document List 양쪽에 적용된다. + - Explorer tree: 해당 bucket 문서만 남기고, folder는 남은 문서를 포함하는 branch만 표시한다. + - Document List: `All`이 아니면 선택 folder 하위 전체(recursive)의 해당 문서를 표시하고, 각 행에 선택 folder 기준 상위 경로를 muted로 붙여 같은 이름 문서를 구분한다. root 선택 상태면 root 전체의 변경 문서 목록이 된다. `All`은 기존대로 direct children만 표시한다. +4. 필터 중 tree는 임시 view다. + - `All`이 아닌 필터로 전환하면 남은 branch를 모두 펼쳐 표시한다. 필터 중 펼침/접힘은 기존 root별 펼침 상태와 분리된 runtime state에 둔다. + - 선택 folder가 필터로 숨겨지면 root 선택으로 표시하되, 원래 선택을 버리지 않는다. + - `All`로 돌아오면 필터 전의 펼침 상태와 선택 folder를 그대로 복원한다. +5. 필터 컨트롤은 Document List header의 cycle icon button 하나다. + - click마다 `All → Modified+New → Modified → New → All`로 순환하고 `Modified+New`는 두 bucket icon을 함께 표시한다. `All`이 아니면 `data-active`로 AI accent 강조한다. current/next state를 설명하는 `aria-label`·tooltip을 제공한다. + - AI header 순서는 `Refresh → Filter → Sort → Density → Create`가 된다. Human과 git work tree가 아닌 AI root는 기존 `Refresh → Sort → Density → Create`를 유지한다. + - list pane은 3-pane/2-pane 모두에서 보이므로 별도 fallback이 없다. content-only에서는 보이지 않지만 필터 상태는 유지된다. + - header 제목 옆 count는 필터 중 `5 changed` / `3 modified` / `2 new`처럼 bucket을 표시한다. +6. 필터 상태는 root별 runtime-only다. folder 선택·펼침 상태와 같은 `docsRuntime.navigation` 기록에 두어 같은 세션에서 root tab을 오가도 각 root의 필터가 유지되고, 앱 재시작 시 모든 root가 `All`로 시작한다. `settings.json` 스키마는 바뀌지 않는다. +7. git status는 필터를 `All`에서 다른 필터로 바꿀 때, 그리고 필터가 `All`이 아닌 동안 scan과 같은 시점(Refresh, root 활성화, mutation 후 재scan)에만 읽는다. `All`에서는 work-tree 판별만 한다(1번). 저장 후나 window focus 시에는 다시 읽지 않는다. 필터 목록은 "마지막 Refresh 시점의 상태"이며, 앱에서 편집한 기존 문서의 bucket 변화는 다음 Refresh 때 반영된다. active 문서가 현재 bucket에 없으면 Document List에 선택 row가 없을 수 있고, tab은 그대로 유지한다. +8. git 명령은 read-only만 사용한다. mutating git 명령은 어떤 경우에도 실행하지 않는다. + +## Non-goals + +- Human space에는 필터를 제공하지 않는다. +- 여러 repo를 담은 상위 폴더처럼 root 자체가 git work tree가 아니면 필터를 숨긴다. 하위 repo별 status 집계는 하지 않는다. +- submodule 내부 문서는 분류하지 않는다(`All`에서만 보인다). +- `All`에서 행별 `M`/`N` badge를 표시하지 않는다. +- staged/unstaged 구분, stage·commit 등 git 쓰기 동작을 제공하지 않는다. +- HEAD 외 branch나 commit과의 비교를 제공하지 않는다. +- file watch나 window focus 기반 자동 갱신을 하지 않는다. + +## Planning hints + +- 새 command `read_git_status(root)`는 `scan_docs_root`와 분리한다. git 실패가 scan을 깨지 않게 한다. +- `git_read_only()` 재사용: `status --porcelain=v1 -z --untracked-files=all -- .` + `rev-parse --show-prefix`. `-z` rename entry는 경로가 두 개다. +- `xcode-select` guard와 work-tree 확인은 `git_head_baseline()`과 공유한다. +- 큰 repo에서 main thread가 멈추지 않도록 이 command는 async로 만든다. +- Frontend는 documents만 필터하고 folders를 남은 문서 경로로 재계산한다. +- 계약 갱신 대상: `CLAUDE.md`, `docs/specs/intent-memo.md`, `DESIGN.md`, README. + +## Open questions + +_None._ + +## Decision Log + +2026-10-14 · Q1 진입 방식 → A. 별도 토글 없이 work tree 감지 시 필터 자동 노출, 기본 `All` · diff toggle과 같은 노출 원칙, `All`이 현재 동작이라 끌 필요 없음 +2026-10-14 · Q2 필터 bucket 정의 → A. HEAD 기준 `All | Modified | New` · View diff의 HEAD baseline 분류와 항상 일치 +2026-10-14 · Q3 필터 적용 시 Document List 범위 → A. `Modified`/`New`에서 선택 folder 하위 recursive + 상위 경로 표시, 필터는 Explorer folder에도 적용 · 여러 folder에 흩어진 AI 변경을 한 목록에서 검토 +2026-10-14 · Q4 필터 전환 시 tree 상태 → A. 필터 중 자동 펼침 + 숨겨진 선택은 root로 표시, `All` 복귀 시 원래 펼침·선택 복원 · 필터된 tree는 작고, 잠깐 보고 끄는 사용 패턴에서 탐색 위치를 잃지 않음 +2026-10-14 · Q5 컨트롤 위치와 모양 → A. list header cycle icon button, 순서 `Refresh → Filter → Sort → Density → Create` · 기존 3-state 컨트롤(Density, diff)과 같은 패턴, 3/2-pane 공통 위치라 fallback 불필요 +2026-10-14 · Q6 필터 상태 저장 → A. root별 runtime-only, 재시작 시 `All` · 선택·펼침과 같은 navigation 기록이라 Q4 복원을 한곳에서 처리, 재시작 후 필터된 목록으로 인한 혼란 방지, settings 스키마 불변 +2026-10-14 · Q7 status 갱신 시점 → A. scan 시점 + `All`에서 필터 전환 시에만, 저장/focus 시 재조회 없음 · 기존 "마지막 Refresh 시점" 원칙 유지, 검토 중 목록 안정, autosave마다 git 실행 없음 +2026-10-14 · Q8 v1 범위와 non-goals → A. 제안 목록 그대로 확정 · v1은 diff toggle과 같은 "root 하나 = repo 하나" 범위, 상위 폴더 집계와 `All` badge는 후속 +2026-10-14 · Q9 Modified+New 통합 보기 → 4단계 순환 `All → Modified+New → Modified → New → All`, 통합 상태는 두 icon 표시 · 리뷰 시 둘을 함께 보는 경우가 많아 1 click으로 도달, 개별 bucket 보기는 유지 (왕복 click 수는 끝에 둘 때와 동일) diff --git a/docs/specs/intent-memo.md b/docs/specs/intent-memo.md index 4833c09..ddc4c95 100644 --- a/docs/specs/intent-memo.md +++ b/docs/specs/intent-memo.md @@ -91,7 +91,7 @@ Human root는 마지막 단계 선택 시 commit한다. AI 첫 진입은 하나 1. folder pane 상단의 `Human Brain · Bot AI` radio switcher가 두 아이콘을 중앙에 두고 현재 space를 표시·전환한다. 가운데 화살표는 active space에서 target space를 향해 Human 선택 시 `Human → AI`, AI 선택 시 `Human ← AI`로 바뀐다. 2. folder pane은 active space root의 디렉토리 트리를 표시하고, 트리 최상위 이름에는 고정된 `Library` 대신 사용자가 선택한 폴더의 최종 이름을 사용한다. 3. switcher 아래에서 Human Source Card는 78px, 39px 두 줄을 유지한다. AI Source Card는 39px 첫 줄에 pinned label shortcut과 `AI 폴더 열기`를 표시하고, 아래에는 pinned 여부와 관계없이 모든 열린 root를 세로 목록으로 표시한다. visible path는 끝 두 segment만 `…/parent/leaf`로 표시하고 pinned row는 `label | …/parent/leaf` 형태로 label을 함께 표시한다. 목록은 최대 네 줄 높이에서 card 내부 scroll을 사용한다. header shortcut은 label-only root 선택 button이고, 각 path row는 primary 선택 button, direct Pin toggle, 별도 ellipsis menu button을 사용한다. Pin toggle은 unpinned에서 muted, pinned에서 AI accent/fill로 표시한다. unpinned click은 label 입력 후 pin하고 pinned click은 즉시 unpin한다. ellipsis menu는 pinned에서 `Edit label`, unpinned에서 `Close`만 제공하고 unavailable root에는 targeted `Refresh`를 추가한다. menu는 첫 item focus, Arrow Up/Down, Home/End, Enter/Space, Esc와 outside click을 지원하며 종료 후 root opener로 focus를 복원한다. -4. AI 문서 목록 header는 pin 여부와 무관하게 `Refresh → Sort → Density → Create`를 표시한다. Create는 `새 문서` NameDialog로 선택 folder에 Markdown을 만들고 Edit mode tab으로 연다. active root가 unavailable이면 Create를 비활성화한다. 다른 AI 폴더 열기는 Source Card의 `AI 폴더 열기`가 담당한다. scanner는 `.gitignore`와 `.ignore`를 존중하고 hidden/symlink를 제외해 Markdown을 포함한 branch만 scan한다. AI Explorer는 file/folder를 섞어 표시하고 folder click으로 선택과 inline expand/collapse를 함께 수행한다. file activation과 active session 복원은 tab을 열고 해당 file의 parent folder를 선택한다. 가운데 Document List는 선택 folder의 direct Markdown children만 표시하고 active file row를 selected로 표시한다. 선택 row가 document·snippet·density·pane-size 변화 뒤 viewport 밖이면 nearest 위치로 scroll하되 이미 보이는 row와 DOM focus는 유지한다. missing root는 tab, optional label, session을 유지하고 localized notice와 targeted Refresh를 제공한다. +4. AI 문서 목록 header는 pin 여부와 무관하게 `Refresh → Sort → Density → Create`를 표시하고, active root가 git work tree 안이면 `Refresh → Filter → Sort → Density → Create`로 git status filter를 추가한다. Create는 `새 문서` NameDialog로 선택 folder에 Markdown을 만들고 Edit mode tab으로 연다. active root가 unavailable이면 Create를 비활성화한다. 다른 AI 폴더 열기는 Source Card의 `AI 폴더 열기`가 담당한다. scanner는 `.gitignore`와 `.ignore`를 존중하고 hidden/symlink를 제외해 Markdown을 포함한 branch만 scan한다. AI Explorer는 file/folder를 섞어 표시하고 folder click으로 선택과 inline expand/collapse를 함께 수행한다. file activation과 active session 복원은 tab을 열고 해당 file의 parent folder를 선택한다. 가운데 Document List는 선택 folder의 direct Markdown children만 표시하고 active file row를 selected로 표시한다. 선택 row가 document·snippet·density·pane-size 변화 뒤 viewport 밖이면 nearest 위치로 scroll하되 이미 보이는 row와 DOM focus는 유지한다. git status filter는 아래 5.3의 계약을 따른다. missing root는 tab, optional label, session을 유지하고 localized notice와 targeted Refresh를 제공한다. 5. Human과 AI tab은 제목 한 줄이다. AI tab은 badge를 사용하지 않되 tooltip과 accessible name에는 canonical 전체 경로를 유지한다. pinned Explorer root row는 `[label] basename`을 표시한다. Pin은 pinned group 맨 뒤로, Unpin은 session을 유지한 채 unpinned group 맨 앞으로 옮긴다. 일반 tab만 닫을 수 있고 닫기는 해당 root-local session만 제거하며 disk 파일은 건드리지 않는다. 6. 활성 문서에서 `⌘F` 또는 `Ctrl+F`는 content pane 우측 상단의 비모달 현재 문서 검색 overlay를 연다. 대소문자를 구분하지 않는 literal match의 `current/total`을 표시하고 Enter/Shift+Enter 및 다음/이전 button으로 순환한다. Escape와 닫기 button은 overlay를 닫고 이전 focus를 복원한다. Human/AI Edit는 source selection, View는 rendered mark, Split은 두 surface에 같은 active result를 표시한다. 검색은 현재 Markdown body에만 적용하고 workspace scan/index나 persistence를 추가하지 않는다. 7. macOS native traffic lights를 유지한 38px overlay titlebar를 사용한다. `Tasteful Intent`는 왼쪽에, 현재 문서 제목은 pane 구성과 무관한 창 중앙에 표시하며 action이나 경로는 추가하지 않는다. @@ -110,6 +110,9 @@ content pane의 각 문서 tab도 같은 방식으로 열리는 context menu를 - IME 조합 중에는 autosave나 외부 state 동기화가 조합 입력을 끊지 않는다. - View mode는 저장 대상과 같은 본문을 Markdown으로 렌더링한다. AI space의 rendered view는 tab·pane 폭 제약을 보완하기 위해 본문 맨 위에 `…/parent/file.md` document-path 라인을 표시한다. tooltip은 canonical 전체 경로이고 본문과 함께 scroll되며 Human space와 PDF export에는 나타나지 않는다. 경로 텍스트 뒤에는 파일 이름 복사와 canonical 전체 경로 복사용 아이콘+라벨 pill 버튼 두 개가 붙고, 복사 성공은 현재 언어의 polite live-region 메시지로도 알린다. 라인 오른쪽 끝에는 문서를 읽거나 저장한 시점의 on-disk 수정 시각을 표시해 reload 후 파일 갱신 여부를 확인할 수 있다. - AI View는 활성 문서 본문을 git HEAD baseline과 비교하는 read-only diff toggle을 제공한다. diff 계산은 mutating git 명령을 실행하지 않으며, untracked 문서는 전체 본문을 추가로 표시하고 git 또는 repository를 사용할 수 없으면 toggle을 숨긴다. toggle은 끄기 → 변경만(미변경 구간 collapse) → 전체 문서(변경 강조, collapse 없음) → 끄기 순으로 순환한다. +- AI 문서 목록은 active root가 git work tree 안일 때 git status filter cycle button(`All → Modified+New → Modified → New → All`)을 제공한다. 함께 검토하는 경우가 많아 `Modified+New`를 `All` 바로 다음에 둔다. 분류는 diff와 같은 HEAD 기준이다. `Modified`는 HEAD에 같은 경로가 있고 내용이 다른 문서(staged/unstaged 구분 없음), `New`는 HEAD에 경로가 없는 문서(untracked, staged 신규, rename 대상)이며 디스크에 없는 삭제 문서는 제외한다. root 안의 untracked nested repository 아래 문서는 `New`다. +- filter가 `All`이 아니면 Explorer는 해당 문서와 그 상위 folder만 모두 펼친 채 표시하고, Document List는 선택 folder 하위 전체의 해당 문서를 선택 folder 기준 상위 경로와 함께 표시한다. header count는 `변경 N개`/`수정됨 N개`/`새 문서 N개`로 바뀐다. filter가 선택 folder를 숨기면 root 선택으로 표시하고, `All`로 돌아오면 filter 전의 선택 folder와 펼침 상태를 복원한다. +- git status는 AI scan(Refresh, root 활성화, 생성 후 재scan)과 `All`에서 filter로 전환할 때만 읽고 저장 후나 window focus 때는 읽지 않는다. `All`에서는 work tree 여부만 확인한다. filter 상태는 root별 runtime-only이며 git 또는 repository를 사용할 수 없게 되면 `All`로 돌아가고 button을 숨긴다. Human space, 여러 repository를 담은 상위 folder root, submodule 내부 분류, 자동 갱신은 범위 밖이다. - View/Split의 상대 이미지 `src`는 현재 문서의 canonical `{ root, path }`를 기준으로 root 내부 regular image만 native read하고 Blob URL로 렌더링한다. hidden path, symlink, root 이탈, 비지원 image MIME은 허용하지 않는다. - 기존 문서를 여는 tab은 Human에서 Edit, AI에서 View로 시작한다. Create로 만든 새 문서는 Human/AI 모두 Edit tab으로 연다. 두 공간 모두 mode icon으로 Edit/View/Split을 순환한다. - 현재 문서 검색은 source body 발생 순서를 기준으로 하며 Human/AI Edit/View/Split에서 동일 active result를 반영한다. 검색 query와 active result는 저장하지 않는다. @@ -180,7 +183,7 @@ updated: 2026-08-02T03:04:05.000Z v0.2는 사용자가 편집하는 Human 원본과 folder tab으로 관리하는 AI 결과를 분리한다. 사용자 표시명은 Human/AI이고 내부 space key `intent`/`docs`는 유지한다. - Human `libraryRoot` (`intent`): 인간이 직접 작성하는 canonical source-of-truth, editable -- AI (`docs`): OS Open Folder로 추가한 ordered canonical `{ root, label: string | null }` entries, active `docsRoot`, root별 `tabSessions.docs`다. label이 있으면 pinned이며 selected/expanded navigation과 availability는 runtime-only다. +- AI (`docs`): OS Open Folder로 추가한 ordered canonical `{ root, label: string | null }` entries, active `docsRoot`, root별 `tabSessions.docs`다. label이 있으면 pinned이며 selected/expanded navigation, git status filter, availability는 runtime-only다. 자동 생성·자동 갱신되는 AI 관리 폴더는 후속 범위다. 현재 AI는 사용자가 선택한 폴더를 탐색하고 그 안의 Markdown 본문을 읽고 편집하며, requirement 같은 새 Markdown 문서를 직접 작성할 수 있는 표면이다. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e554b21..0a73b52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub fn run() { library::read_document_baseline, library::read_document_image, library::read_document_snippets, + library::read_git_status, library::create_document, library::save_document, library::rename_document, diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index d317d4f..a9df5a3 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -64,6 +64,14 @@ pub struct DocumentBaseline { pub content: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusSnapshot { + pub status: String, + pub modified_paths: Vec, + pub new_paths: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentSnippet { @@ -231,6 +239,184 @@ pub fn read_document_baseline(root: String, path: String) -> CommandResult CommandResult { + let canonical_root = canonical_root(Path::new(&root))?; + Ok(git_status_snapshot(&canonical_root, include_paths)) +} + +fn git_status_snapshot(root: &Path, include_paths: bool) -> GitStatusSnapshot { + let unavailable = GitStatusSnapshot { + status: "unavailable".to_owned(), + modified_paths: Vec::new(), + new_paths: Vec::new(), + }; + if !git_work_tree_available(root) { + return unavailable; + } + if !include_paths { + return GitStatusSnapshot { + status: "available".to_owned(), + ..unavailable + }; + } + + // Porcelain paths are relative to the repository root, not to `root`. + let Ok(prefix) = git_read_only(root) + .args(["rev-parse", "--show-prefix"]) + .output() + else { + return unavailable; + }; + if !prefix.status.success() { + return unavailable; + } + let prefix = String::from_utf8_lossy(&prefix.stdout) + .trim_end_matches(['\n', '\r']) + .to_owned(); + let Ok(output) = git_read_only(root) + .args([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--no-renames", + "--", + ".", + ]) + .output() + else { + return unavailable; + }; + if !output.status.success() { + return unavailable; + } + + let GitStatusClassification { + mut modified, + new, + staged_and_edited, + } = classify_git_status(&output.stdout, &prefix); + if !staged_and_edited.is_empty() { + let differing = git_paths_differing_from_head(root, &prefix, &staged_and_edited); + modified.extend( + staged_and_edited + .into_iter() + .filter(|path| differing.as_ref().is_none_or(|paths| paths.contains(path))), + ); + } + GitStatusSnapshot { + status: "available".to_owned(), + modified_paths: modified.into_iter().collect(), + new_paths: new.into_iter().collect(), + } +} + +/// Root-relative paths among `paths` whose working tree differs from HEAD, or +/// `None` when git cannot tell, in which case the caller keeps every path. +fn git_paths_differing_from_head( + root: &Path, + prefix: &str, + paths: &[String], +) -> Option> { + let output = git_read_only(root) + .args(["diff", "--name-only", "-z", "--no-renames", "HEAD", "--"]) + .args(paths.iter().map(|path| format!(":(literal){path}"))) + .output() + .ok() + .filter(|output| output.status.success())?; + Some( + output + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .filter_map(|record| { + String::from_utf8_lossy(record) + .strip_prefix(prefix) + .map(str::to_owned) + }) + .collect(), + ) +} + +#[derive(Debug, Default)] +struct GitStatusClassification { + modified: BTreeSet, + new: BTreeSet, + /// Changed in both the index and the working tree (`MM`, `MT`, ...). The + /// working tree may have been restored to HEAD after staging, so only git + /// diff against HEAD can tell whether these are modified. + staged_and_edited: Vec, +} + +/// Splits `git status --porcelain=v1 -z --no-renames` records into documents +/// that differ from HEAD (`modified`) and documents HEAD does not have (`new`), +/// matching how `read_document_baseline` treats a path missing from HEAD. +/// Deleted documents are dropped because they cannot be opened. A `new` entry +/// ending in `/` is an untracked nested repository covering every document +/// below it. Paths keep git's `/` separators, matching scanned document paths. +fn classify_git_status(output: &[u8], prefix: &str) -> GitStatusClassification { + let mut classified = GitStatusClassification::default(); + let GitStatusClassification { + modified, + new, + staged_and_edited, + } = &mut classified; + let mut index_deleted = BTreeSet::new(); + let mut untracked = Vec::new(); + for record in output.split(|byte| *byte == 0) { + if record.len() < 4 || record[2] != b' ' { + continue; + } + let path = String::from_utf8_lossy(&record[3..]); + let Some(relative) = path.strip_prefix(prefix) else { + continue; + }; + if !(relative.ends_with(".md") || relative.ends_with('/')) { + continue; + } + let relative = relative.to_owned(); + match [record[0], record[1]] { + [b'?', b'?'] => untracked.push(relative), + [b'D', b' '] => { + index_deleted.insert(relative); + } + [b'A', b'A'] | [b'A', b'U'] | [b'U', b'A'] | [b'D', b'U'] => { + new.insert(relative); + } + [b'U', b'U'] | [b'U', b'D'] => { + modified.insert(relative); + } + [b'A', worktree] if worktree != b'D' => { + new.insert(relative); + } + [index, worktree] + if matches!(index, b'M' | b'T') && matches!(worktree, b'M' | b'T') => + { + staged_and_edited.push(relative); + } + [index, worktree] + if index != b'D' + && worktree != b'D' + && (matches!(index, b'M' | b'T') || matches!(worktree, b'M' | b'T')) => + { + modified.insert(relative); + } + _ => {} + } + } + // `git rm --cached` leaves HEAD's path on disk as untracked; HEAD still has it. + for relative in untracked { + if index_deleted.contains(&relative) { + modified.insert(relative); + } else { + new.insert(relative); + } + } + classified +} + fn baseline_unavailable() -> DocumentBaseline { DocumentBaseline { status: "unavailable".to_owned(), @@ -247,7 +433,7 @@ fn git_read_only(root: &Path) -> std::process::Command { command } -fn git_head_baseline(root: &Path, relative: &str) -> DocumentBaseline { +fn git_work_tree_available(root: &Path) -> bool { // Without the Command Line Tools installed, the /usr/bin/git shim pops a GUI // install prompt instead of failing, so block on that first. #[cfg(target_os = "macos")] @@ -256,17 +442,20 @@ fn git_head_baseline(root: &Path, relative: &str) -> DocumentBaseline { .arg("-p") .output(); if !selected.is_ok_and(|output| output.status.success()) { - return baseline_unavailable(); + return false; } } - let inside = git_read_only(root) + git_read_only(root) .args(["rev-parse", "--is-inside-work-tree"]) - .output(); - let Ok(inside) = inside else { - return baseline_unavailable(); - }; - if !inside.status.success() || String::from_utf8_lossy(&inside.stdout).trim() != "true" { + .output() + .is_ok_and(|output| { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" + }) +} + +fn git_head_baseline(root: &Path, relative: &str) -> DocumentBaseline { + if !git_work_tree_available(root) { return baseline_unavailable(); } @@ -1033,10 +1222,10 @@ fn io_error(code: &'static str, context: &str, cause: std::io::Error) -> Command #[cfg(test)] mod tests { use super::{ - DOCUMENT_IMAGE_MAX_BYTES, atomic_save, create_document, create_folder, extract_snippet, - move_entry, normalize_relative, read_document_baseline, read_document_image, - read_document_snippets, rename_document, resolve_document_source, resolve_library_root, - scan_docs_root, scan_library, + DOCUMENT_IMAGE_MAX_BYTES, atomic_save, classify_git_status, create_document, create_folder, + extract_snippet, move_entry, normalize_relative, read_document_baseline, + read_document_image, read_document_snippets, read_git_status, rename_document, + resolve_document_source, resolve_library_root, scan_docs_root, scan_library, }; use std::fs; use std::path::Path; @@ -1592,4 +1781,122 @@ mod tests { assert_eq!(baseline.status, "unavailable"); } + + fn commit_all(root: &Path) { + run_git(root, &["add", "."]); + run_git( + root, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "seed", + ], + ); + } + + #[test] + fn classifies_git_status_relative_to_a_subdirectory_root() { + if !git_available() { + return; + } + let directory = tempdir().expect("temp dir"); + let repository = directory.path().canonicalize().expect("canonical root"); + let root = repository.join("docs"); + fs::create_dir_all(root.join("sub")).expect("folders"); + fs::write(repository.join("outside.md"), "outside\n").expect("outside"); + fs::write(root.join("edited.md"), "before\n").expect("edited"); + fs::write(root.join("clean.md"), "clean\n").expect("clean"); + fs::write(root.join("sub/renamed.md"), "rename\n").expect("renamed"); + fs::write(root.join("gone.md"), "gone\n").expect("gone"); + fs::write(root.join("notes.txt"), "text\n").expect("text"); + fs::write(root.join("sub/reverted.md"), "head\n").expect("reverted"); + fs::write(root.join("sub/restaged.md"), "head\n").expect("restaged"); + run_git(&repository, &["init", "--quiet"]); + commit_all(&repository); + + fs::write(repository.join("outside.md"), "changed\n").expect("outside edit"); + fs::write(root.join("edited.md"), "after\n").expect("edit"); + fs::write(root.join("notes.txt"), "changed\n").expect("text edit"); + fs::write(root.join("sub/untracked.md"), "fresh\n").expect("untracked"); + fs::write(root.join("staged.md"), "staged\n").expect("staged"); + fs::remove_file(root.join("gone.md")).expect("delete"); + run_git(&repository, &["add", "docs/staged.md"]); + fs::write(root.join("sub/reverted.md"), "staged\n").expect("stage edit"); + fs::write(root.join("sub/restaged.md"), "staged\n").expect("stage edit"); + run_git( + &repository, + &["add", "docs/sub/reverted.md", "docs/sub/restaged.md"], + ); + fs::write(root.join("sub/reverted.md"), "head\n").expect("revert"); + fs::write(root.join("sub/restaged.md"), "again\n").expect("edit again"); + run_git( + &repository, + &["mv", "docs/sub/renamed.md", "docs/sub/moved.md"], + ); + + let status = read_git_status(root.to_str().expect("utf-8 root").to_owned(), true) + .expect("status result"); + + assert_eq!(status.status, "available"); + assert_eq!(status.modified_paths, vec!["edited.md", "sub/restaged.md"]); + assert_eq!( + status.new_paths, + vec!["staged.md", "sub/moved.md", "sub/untracked.md"] + ); + } + + #[test] + fn reports_git_availability_without_reading_paths() { + if !git_available() { + return; + } + let directory = tempdir().expect("temp dir"); + let root = directory.path().canonicalize().expect("canonical root"); + run_git(&root, &["init", "--quiet"]); + fs::write(root.join("new.md"), "fresh\n").expect("write"); + + let status = read_git_status(root.to_str().expect("utf-8 root").to_owned(), false) + .expect("status result"); + + assert_eq!(status.status, "available"); + assert!(status.modified_paths.is_empty()); + assert!(status.new_paths.is_empty()); + } + + #[test] + fn reports_git_status_unavailable_outside_a_git_repository() { + let directory = tempdir().expect("temp dir"); + let root = directory.path().canonicalize().expect("canonical root"); + fs::write(root.join("plain.md"), "text\n").expect("write"); + + let status = read_git_status(root.to_str().expect("utf-8 root").to_owned(), true) + .expect("status result"); + + assert_eq!(status.status, "unavailable"); + assert!(status.new_paths.is_empty()); + } + + #[test] + fn classifies_nested_repositories_conflicts_and_untracked_head_paths() { + let output = b"?? nested/\0UU conflict.md\0AA both.md\0D cached.md\0?? cached.md\0 D removed.md\0MM staged-and-edited.md\0AM added-then-edited.md\0?? image.png\0"; + + let classified = classify_git_status(output, ""); + + assert_eq!( + classified.modified.into_iter().collect::>(), + vec!["cached.md", "conflict.md"] + ); + assert_eq!( + classified.new.into_iter().collect::>(), + vec!["added-then-edited.md", "both.md", "nested/"] + ); + assert_eq!(classified.staged_and_edited, vec!["staged-and-edited.md"]); + } } diff --git a/src/App.test.tsx b/src/App.test.tsx index e01afe7..138a93a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -32,6 +32,7 @@ type MediaQueryListState = { readonly removals: Map; }; type WorkspaceOptions = { + readonly documentFilter?: ((path: string) => boolean) | null; readonly initialSession?: TabSession; readonly initialSnapshot?: unknown; readonly onSessionChange?: (session: TabSession) => void; @@ -69,6 +70,9 @@ const testState = vi.hoisted(() => { ], documents: [], }, + get explorerSnapshot() { + return this.snapshot; + }, visibleDocuments: [], visibleSnippets: new Map(), selectedFolder: "", @@ -152,6 +156,7 @@ const native = vi.hoisted(() => ({ readonly mimeType: string; }> >(), + readGitStatus: vi.fn(), resolveLibraryRoot: vi.fn<(path: string) => Promise>(), scanDocsRoot: vi.fn(), })); @@ -186,6 +191,7 @@ vi.mock("@/lib/native", async (importOriginal) => ({ printDocument: native.printDocument, readDocumentBaseline: native.readDocumentBaseline, readDocumentImage: native.readDocumentImage, + readGitStatus: native.readGitStatus, resolveLibraryRoot: native.resolveLibraryRoot, scanDocsRoot: native.scanDocsRoot, })); @@ -234,6 +240,11 @@ beforeEach(() => { content: null, status: "unavailable", }); + native.readGitStatus.mockResolvedValue({ + modifiedPaths: [], + newPaths: [], + status: "unavailable", + }); native.resolveLibraryRoot.mockImplementation(async (path) => path); testState.settings.libraryRoot = "/intent"; testState.settings.docsRoots = [{ root: "/docs", label: null }]; @@ -935,6 +946,167 @@ describe("document list controls", () => { }); }); +describe("AI git status filter", () => { + const allLabel = "현재 전체 문서 · 클릭하면 마지막 commit 이후 수정·새 문서"; + const changedLabel = "현재 수정·새 문서 · 클릭하면 수정된 문서만"; + const modifiedLabel = "현재 수정된 문서 · 클릭하면 새 문서"; + const newLabel = "현재 마지막 commit 이후 새 문서 · 클릭하면 전체 문서"; + const latestOptions = () => testState.workspaceOptions.at(-1); + const runWorkspaceScan = async () => { + await screen.findByRole("listbox", { name: "Markdown 문서" }); + const scan = latestOptions()?.scan as (root: string) => Promise; + await act(async () => { + await scan("/docs"); + }); + }; + + beforeEach(() => { + testState.settings.activeSpace = "docs"; + testState.workspace.selectedFolder = "projects"; + testState.workspace.visibleDocuments = [ + { + path: "projects/current/plan.md", + parent: "projects/current", + title: "plan", + updatedMs: 1, + }, + ]; + native.scanDocsRoot.mockResolvedValue({ folders: [], documents: [] }); + native.readGitStatus.mockImplementation( + async (_root: string, includePaths: boolean) => ({ + modifiedPaths: includePaths ? ["projects/current/plan.md"] : [], + newPaths: includePaths ? ["draft.md"] : [], + status: "available", + }), + ); + }); + + afterEach(() => { + testState.workspace.selectedFolder = ""; + }); + + it("appears only after a scan finds a work tree and checks availability alone in All", async () => { + render(); + await screen.findByRole("listbox", { name: "Markdown 문서" }); + expect(screen.queryByRole("button", { name: allLabel })).toBeNull(); + + await runWorkspaceScan(); + + expect(native.scanDocsRoot).toHaveBeenCalledWith("/docs"); + expect(native.readGitStatus).toHaveBeenCalledWith("/docs", false); + const toggle = await screen.findByRole("button", { name: allLabel }); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(latestOptions()?.documentFilter).toBeNull(); + }); + + it("cycles All, Modified+New, Modified, and New, rereading status only when leaving All", async () => { + const user = userEvent.setup(); + const { container } = render(); + await runWorkspaceScan(); + native.readGitStatus.mockClear(); + + await user.click(await screen.findByRole("button", { name: allLabel })); + + expect(native.readGitStatus).toHaveBeenCalledExactlyOnceWith("/docs", true); + const changed = await screen.findByRole("button", { name: changedLabel }); + expect(changed.getAttribute("aria-pressed")).toBe("true"); + expect(changed.dataset.gitFilter).toBe("changed"); + expect(changed.querySelectorAll("svg")).toHaveLength(2); + expect(latestOptions()?.documentFilter?.("projects/current/plan.md")).toBe( + true, + ); + expect(latestOptions()?.documentFilter?.("draft.md")).toBe(true); + expect(latestOptions()?.documentFilter?.("clean.md")).toBe(false); + expect(screen.getByText("변경 1개")).toBeTruthy(); + expect(container.querySelector(".document-parent-path")?.textContent).toBe( + "current", + ); + + await user.click(changed); + + const modified = await screen.findByRole("button", { + name: modifiedLabel, + }); + expect(modified.querySelectorAll("svg")).toHaveLength(1); + expect(latestOptions()?.documentFilter?.("projects/current/plan.md")).toBe( + true, + ); + expect(latestOptions()?.documentFilter?.("draft.md")).toBe(false); + expect(screen.getByText("수정됨 1개")).toBeTruthy(); + + await user.click(modified); + + expect(native.readGitStatus).toHaveBeenCalledOnce(); + await screen.findByRole("button", { name: newLabel }); + expect(latestOptions()?.documentFilter?.("draft.md")).toBe(true); + expect(screen.getByText("새 문서 1개")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: newLabel })); + + await screen.findByRole("button", { name: allLabel }); + expect(latestOptions()?.documentFilter).toBeNull(); + expect(testState.workspace.setSelectedFolder).toHaveBeenLastCalledWith( + "projects", + ); + expect(container.querySelector(".document-parent-path")).toBeNull(); + }); + + it("expands the filtered tree and restores the unfiltered expansion afterwards", async () => { + const user = userEvent.setup(); + const { container } = render(); + await runWorkspaceScan(); + const folderRow = (path: string) => + container.querySelector(`.file-explorer-tree [title="${path}"]`); + expect(folderRow("projects/current/child")).toBeNull(); + + await user.click(await screen.findByRole("button", { name: allLabel })); + await screen.findByRole("button", { name: changedLabel }); + + expect(folderRow("projects/current/child")).not.toBeNull(); + await user.click(folderRow("projects") as HTMLElement); + expect(folderRow("projects/current")).toBeNull(); + + await user.click(screen.getByRole("button", { name: changedLabel })); + await user.click(screen.getByRole("button", { name: modifiedLabel })); + await user.click(screen.getByRole("button", { name: newLabel })); + await screen.findByRole("button", { name: allLabel }); + + expect(folderRow("projects")).not.toBeNull(); + expect(folderRow("projects/current")).toBeNull(); + }); + + it("falls back to All and hides the control when git becomes unavailable", async () => { + const user = userEvent.setup(); + render(); + await runWorkspaceScan(); + await user.click(await screen.findByRole("button", { name: allLabel })); + await screen.findByRole("button", { name: changedLabel }); + + native.readGitStatus.mockResolvedValue({ + modifiedPaths: [], + newPaths: [], + status: "unavailable", + }); + await runWorkspaceScan(); + + await waitFor(() => + expect(screen.queryByRole("button", { name: changedLabel })).toBeNull(), + ); + expect(screen.queryByRole("button", { name: allLabel })).toBeNull(); + expect(latestOptions()?.documentFilter).toBeNull(); + expect(screen.getByText("메모 1개")).toBeTruthy(); + }); + + it("never reads git status in the Human space", async () => { + testState.settings.activeSpace = "intent"; + render(); + await screen.findByRole("listbox", { name: "Markdown 문서" }); + + expect(native.readGitStatus).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: allLabel })).toBeNull(); + }); +}); + describe("root selection onboarding", () => { it("completes after language, theme, and the required Human folder", async () => { testState.settings.libraryRoot = null; diff --git a/src/App.tsx b/src/App.tsx index 3a3ed38..07529a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,10 @@ import { Eye, FileDiff, FileDown, + FilePenLine, + FilePlusCorner, FoldVertical, + ListFilter, PanelLeft, PencilLine, Plus, @@ -49,11 +52,18 @@ import { suggestDocsFolderLabel, validateDocsFolderLabel, } from "@/lib/docsFolderLabel"; +import { + createGitDocumentFilter, + type GitFilter, + type GitStatusPaths, + matchNoDocument, +} from "@/lib/gitStatusFilter"; import { getMessages, I18nProvider, type Messages, useI18n } from "@/lib/i18n"; import { parseMarkdown } from "@/lib/markdown"; import { printDocument, readDocumentBaseline, + readGitStatus, resolveLibraryRoot, scanDocsRoot, } from "@/lib/native"; @@ -102,14 +112,29 @@ type DiffViewControl = { readonly next: DiffViewMode; }; +type GitFilterControl = { + readonly icon: LucideIcon; + readonly secondIcon?: LucideIcon; + readonly next: GitFilter; +}; + type SettingsUpdater = (current: LayoutSettings) => LayoutSettings; type SettingsChange = (update: SettingsUpdater) => Promise; type DocsNavigationState = { readonly selectedFolder: string; readonly expandedPaths: ReadonlySet; + readonly gitFilter: GitFilter; + // A filtered tree starts fully expanded, so it tracks collapses instead. + readonly filterCollapsedPaths: ReadonlySet; + readonly selectedFolderBeforeFilter: string; }; +type GitStatusState = + | { readonly status: "checking" } + | { readonly status: "unavailable" } + | { readonly status: "available"; readonly paths: GitStatusPaths | null }; + type DocsRuntimeState = { readonly navigation: Map; readonly availability: Map; @@ -163,6 +188,15 @@ const DIFF_VIEW_CONTROLS = { full: { icon: UnfoldVertical, next: "off" }, } satisfies Record; +// `changed` sits right after `all` because reviewing modified and new +// documents together is the common case; it shows both bucket icons. +const GIT_FILTER_CONTROLS = { + all: { icon: ListFilter, next: "changed" }, + changed: { icon: FilePenLine, secondIcon: FilePlusCorner, next: "modified" }, + modified: { icon: FilePenLine, next: "new" }, + new: { icon: FilePlusCorner, next: "all" }, +} satisfies Record; + type WindowFrameProps = { readonly children: ReactNode; readonly documentTitle?: string; @@ -591,16 +625,88 @@ function LibraryApp({ const rootEntry = settings.docsRoots.find( ({ root: entryRoot }) => entryRoot === root, ); - const initialNavigation = docsRuntime.navigation.get(root) ?? { - selectedFolder: "", - expandedPaths: new Set(), - }; + const [initialNavigation] = useState( + () => + docsRuntime.navigation.get(root) ?? { + selectedFolder: "", + expandedPaths: new Set(), + gitFilter: "all", + filterCollapsedPaths: new Set(), + selectedFolderBeforeFilter: "", + }, + ); const [initialSnapshot] = useState(() => docsRuntime.preflightSnapshots.get(root), ); const [expandedPaths, setExpandedPaths] = useState>( initialNavigation.expandedPaths, ); + const updateNavigation = useCallback( + (partial: Partial) => { + const previous = docsRuntime.navigation.get(root) ?? initialNavigation; + docsRuntime.navigation.set(root, { ...previous, ...partial }); + }, + [docsRuntime, initialNavigation, root], + ); + const [gitFilter, setGitFilter] = useState( + initialNavigation.gitFilter, + ); + const gitFilterRef = useRef(gitFilter); + const gitFilterPendingRef = useRef(false); + const [filterCollapsedPaths, setFilterCollapsedPaths] = useState< + ReadonlySet + >(initialNavigation.filterCollapsedPaths); + const [gitStatus, setGitStatus] = useState({ + status: "checking", + }); + const gitStatusRequestRef = useRef(0); + const loadGitStatus = useCallback( + async (includePaths: boolean): Promise => { + const request = gitStatusRequestRef.current + 1; + gitStatusRequestRef.current = request; + let next: GitStatusState; + try { + const payload = await readGitStatus(root, includePaths); + next = + payload.status === "available" + ? { + status: "available", + paths: includePaths + ? { + modifiedPaths: payload.modifiedPaths, + newPaths: payload.newPaths, + } + : null, + } + : { status: "unavailable" }; + } catch { + next = { status: "unavailable" }; + } + if (request === gitStatusRequestRef.current) setGitStatus(next); + return next; + }, + [root], + ); + // Git status is read alongside every AI scan (Refresh, root activation, + // post-mutation rescans) and never after saves. `All` only needs to know + // whether a work tree exists, so it skips the path list. + const scanDocsRootWithGitStatus = useCallback( + async (target: string) => { + const [snapshot] = await Promise.all([ + scanDocsRoot(target), + loadGitStatus(gitFilterRef.current !== "all"), + ]); + return snapshot; + }, + [loadGitStatus], + ); + const documentFilter = useMemo(() => { + if (!aiMode || gitFilter === "all") return null; + if (gitStatus.status !== "available" || !gitStatus.paths) { + return matchNoDocument; + } + return createGitDocumentFilter(gitStatus.paths, gitFilter); + }, [aiMode, gitFilter, gitStatus]); const persistTabSession = useCallback( (session: TabSession) => { void onSettingsChange((current) => { @@ -632,6 +738,7 @@ function LibraryApp({ ); const workspace = useLibraryWorkspace(root, { defaultMode, + documentFilter, initialSession: activeSpace === "intent" ? settings.tabSessions.intent @@ -644,14 +751,10 @@ function LibraryApp({ ? initialNavigation.selectedFolder : undefined, onSelectedFolderChange: aiMode - ? (selectedFolder) => { - const previous = - docsRuntime.navigation.get(root) ?? initialNavigation; - docsRuntime.navigation.set(root, { ...previous, selectedFolder }); - } + ? (selectedFolder) => updateNavigation({ selectedFolder }) : undefined, onSessionChange: persistTabSession, - scan: aiMode ? scanDocsRoot : undefined, + scan: aiMode ? scanDocsRootWithGitStatus : undefined, syncFolderToActiveDocument: aiMode, }); useEffect(() => { @@ -662,6 +765,66 @@ function LibraryApp({ docsRuntime.preflightSnapshots.delete(root); } }, [docsRuntime, initialSnapshot, root]); + // A preflight snapshot skips the workspace's first scan, so read git status + // here instead. + useEffect(() => { + if (aiMode && initialSnapshot) { + void loadGitStatus(gitFilterRef.current !== "all"); + } + }, [aiMode, initialSnapshot, loadGitStatus]); + const setSelectedFolder = workspace.setSelectedFolder; + const allFolders = workspace.snapshot.folders; + const leaveGitFilter = useCallback(() => { + const previous = docsRuntime.navigation.get(root) ?? initialNavigation; + const restored = previous.selectedFolderBeforeFilter; + gitFilterRef.current = "all"; + setGitFilter("all"); + updateNavigation({ gitFilter: "all" }); + setSelectedFolder( + restored === "" || allFolders.some((folder) => folder.path === restored) + ? restored + : "", + ); + }, [ + allFolders, + docsRuntime, + initialNavigation, + root, + setSelectedFolder, + updateNavigation, + ]); + const cycleGitFilter = async () => { + if (gitFilterPendingRef.current) return; + const next = GIT_FILTER_CONTROLS[gitFilter].next; + if (next === "all") { + leaveGitFilter(); + return; + } + if (gitFilter === "all") { + gitFilterPendingRef.current = true; + try { + if ((await loadGitStatus(true)).status !== "available") return; + } finally { + gitFilterPendingRef.current = false; + } + updateNavigation({ + selectedFolderBeforeFilter: workspace.selectedFolder, + }); + } + gitFilterRef.current = next; + setGitFilter(next); + setFilterCollapsedPaths(new Set()); + updateNavigation({ gitFilter: next, filterCollapsedPaths: new Set() }); + }; + useEffect(() => { + if (!aiMode || gitFilter === "all") return; + if (gitStatus.status === "unavailable") { + leaveGitFilter(); + } else if (gitStatus.status === "available" && !gitStatus.paths) { + // A concurrent `All` scan can land after the filter turned on. + void loadGitStatus(true); + } + }, [aiMode, gitFilter, gitStatus, leaveGitFilter, loadGitStatus]); const [dialog, setDialog] = useState(null); const [diffBaseline, setDiffBaseline] = useState({ status: "loading", @@ -776,6 +939,44 @@ function LibraryApp({ full: messages.app.diffFull, } satisfies Record; const DiffViewIcon = DIFF_VIEW_CONTROLS[diffView].icon; + const gitFilterLabels = { + all: messages.app.gitFilterAll, + changed: messages.app.gitFilterChanged, + modified: messages.app.gitFilterModified, + new: messages.app.gitFilterNew, + } satisfies Record; + const gitFilterControl: GitFilterControl = GIT_FILTER_CONTROLS[gitFilter]; + const GitFilterIcon = gitFilterControl.icon; + const GitFilterSecondIcon = gitFilterControl.secondIcon; + const gitFilterVisible = + aiMode && (gitStatus.status === "available" || gitFilter !== "all"); + const visibleDocumentCount = workspace.visibleDocuments.length; + const filteredCountLabels = { + changed: messages.app.changedNotes, + modified: messages.app.modifiedNotes, + new: messages.app.newNotes, + } satisfies Record, (count: number) => string>; + const filteredEmptyMessages = { + changed: messages.list.emptyChanged, + modified: messages.list.emptyModified, + new: messages.list.emptyNew, + } satisfies Record, string>; + const documentCountLabel = + documentFilter && gitFilter !== "all" + ? filteredCountLabels[gitFilter](visibleDocumentCount) + : messages.app.notes(visibleDocumentCount); + const explorerFolders = workspace.explorerSnapshot.folders; + const explorerExpandedPaths = useMemo>( + () => + documentFilter + ? new Set( + explorerFolders + .map((folder) => folder.path) + .filter((path) => !filterCollapsedPaths.has(path)), + ) + : expandedPaths, + [documentFilter, expandedPaths, explorerFolders, filterCollapsedPaths], + ); const densityLabels = { full: messages.app.densityFull, medium: messages.app.densityMedium, @@ -1087,11 +1288,18 @@ function LibraryApp({ ]); const toggleExpandedFolder = (path: string) => { + if (documentFilter) { + const next = new Set(filterCollapsedPaths); + if (next.has(path)) next.delete(path); + else next.add(path); + updateNavigation({ filterCollapsedPaths: next }); + setFilterCollapsedPaths(next); + return; + } const next = new Set(expandedPaths); if (next.has(path)) next.delete(path); else next.add(path); - const previous = docsRuntime.navigation.get(root) ?? initialNavigation; - docsRuntime.navigation.set(root, { ...previous, expandedPaths: next }); + updateNavigation({ expandedPaths: next }); setExpandedPaths(next); }; @@ -1382,9 +1590,9 @@ function LibraryApp({ {aiMode ? ( void workspace.openDocument(path)} onSelectFolder={workspace.setSelectedFolder} onToggleFolder={toggleExpandedFolder} @@ -1443,9 +1651,7 @@ function LibraryApp({ ? rootName : folderLabel(workspace.selectedFolder)} - - {messages.app.notes(workspace.visibleDocuments.length)} - + {documentCountLabel}
+ {gitFilterVisible ? ( + + ) : null}