# AGENTS.md

This file provides guidance to Claude Code (claude.ai/code) and Codex when working with code in this repository.

## Commands

```bash
npm start          # Run the Electron app in development
npm run build      # Build macOS app bundle (no DMG, arm64 + x64)
npm run dist       # Build and package as DMG for distribution
./setup.sh         # First-time setup: npm install + npm start
```

There are no tests or linting configured.

## Architecture

This is a macOS Electron desktop app for batch-renaming and moving photo files. It follows strict Electron security practices: `contextIsolation: true`, `nodeIntegration: false`.

**Process boundary:** All filesystem access lives exclusively in `src/main/` (main process). The renderer never touches `fs` directly.

### File structure

```
assets/
├── icon.png          # Source icon (2048×2048, square)
└── icon.icns         # macOS app icon for electron-builder

src/
├── main/
│   ├── index.js          # Main process entry point; app.setName(), BrowserWindow, registerAll()
│   ├── config.js         # Shared userData/config.json read/write helpers
│   └── ipc/
│       ├── index.js      # registerAll(mainWindow) — delegates to folder and files modules
│       ├── file-utils.js # Pure helpers: listImages, readImageAsDataUrl, fileExists,
│       │                 # moveFileWithCopyFallback (EXDEV-safe cross-device move)
│       ├── folder.js     # select-folder, list-directories, get/set-last-folder,
│       │                 # get/set-preview-on, get/set-sidebar-width,
│       │                 # get/set-file-explorer-visible, get/set-thumb-size,
│       │                 # get-home-dir, reveal-folder
│       └── files.js      # list-images, read-image, rename-files, stage/restore-delete,
│                         # check-files-exist, move-files
├── preload/
│   └── index.js          # Bridges IPC to window.api via contextBridge (isolated context)
└── renderer/
    ├── index.html        # App shell; dark theme, CSS variables in :root, modal HTML
    ├── style.css         # All CSS
    ├── state.js          # Shared mutable state: files[], currentFolder, selectedIdxs, sortMode
    ├── renderer.js       # Entry point: DOM wiring, init order, preview-name updates
    ├── folder-controller.js  # Folder open/load, directory tree sync, sort mode
    ├── history-controller.js # Undo/redo UI flow, delete undo/redo filesystem calls
    ├── keyboard.js       # Global app shortcuts; disabled while a modal is open
    ├── modal.js          # Common modal open/close, ESC/Enter, focus trap
    ├── ui.js             # showLoading, hideLoading, setStatus, showToast
    ├── grid.js           # renderGrid, createCard, card UI update functions, showEmptyGrid
    ├── rename.js         # doRename + confirm/conflict modals; resets history after rename
    ├── move.js           # handleFolderDrop + move modals + _doMoveFiles; resets history after move
    ├── delete.js         # Delete modal + staged-delete execution; delete is undoable
    ├── history.js        # Undo/redo stack (max 50 entries); reorder/delete entries
    ├── utils/
    │   ├── dom.js        # Small DOM helpers such as renderTextList()
    │   └── file-names.js # Extension parsing, digit clamping, sequential name formatting
    └── handlers/
        ├── drag-drop.js      # Card reorder (single/multi) + drag-to-folder in sidebar
        ├── drag/
        │   ├── auto-scroll.js # Drag auto-scroll helper
        │   └── ghost.js       # Drag ghost UI helper
        ├── file-explorer.js  # Sidebar directory tree (lazy-load, expand/collapse)
        ├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340–500px width
        ├── selection.js      # Rubber-band, Shift-click, Cmd+click, keyboard navigation, resetAnchor
        ├── pinch-zoom.js     # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150–400px)
        └── image-preview.js  # Hover preview overlay; immediate show/hide when enabled/disabled
```

### IPC surface

Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`:

| Channel | Direction | Description |
|---|---|---|
| `select-folder` | invoke | Opens native folder picker, returns path or null |
| `list-images` | invoke | Reads directory, returns `{name, path, mtime}[]` filtered to image extensions, sorted by filename |
| `read-image` | invoke | Reads file, returns base64 data URL (supports jpg/png/gif/webp/tiff/bmp/heic) |
| `rename-files({folderPath, files, prefix, digits, postfix})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names using `prefix + padded sequence + postfix + ext` |
| `check-files-exist(folderPath, names[])` | invoke | Returns subset of names that already exist in folderPath |
| `move-files({targetFolder, files[]})` | invoke | Moves files; each file may have optional `newName`; handles cross-device (EXDEV) |
| `stage-delete-files(paths[])` | invoke | Moves files to userData deleted_staging for undoable deletion; handles cross-device (EXDEV) |
| `restore-staged-files(entries[])` | invoke | Restores staged delete entries back to their original paths |
| `list-directories(folderPath)` | invoke | Returns `{name, path, hasChildren}[]` subdirs, or `null` on access error |
| `get-home-dir` | invoke | Returns `os.homedir()` |
| `get-last-folder` / `set-last-folder` | invoke | Persists last opened folder in userData config.json |
| `get-preview-on` / `set-preview-on` | invoke | Persists hover preview toggle state in userData config.json |
| `get-sidebar-width` / `set-sidebar-width` | invoke | Persists sidebar width in userData config.json |
| `get-file-explorer-visible` / `set-file-explorer-visible` | invoke | Persists file explorer visibility in userData config.json; default is visible |
| `get-thumb-size` / `set-thumb-size` | invoke | Persists thumbnail/card size in userData config.json |
| `reveal-folder` | invoke | Opens folder in Finder via `shell.openPath` |
| `fullscreen-change` | push (main→renderer) | Sent on `enter-full-screen` / `leave-full-screen` window events |

### Renderer (`src/renderer/`)

Uses ES module imports (`type="module"`). No framework.

**Dependency direction** (no cycles):
```
renderer.js → folder-controller.js ─┬→ grid.js → handlers/*
            → history-controller.js ├→ history.js
            → keyboard.js           └→ ui.js
            → rename.js / move.js / delete.js
            → modal.js / utils/*
```

Keep filesystem work in `src/main/`; renderer modules use `window.api` only.
Prefer narrow utility modules (`utils/file-names.js`, `utils/dom.js`) over broad catch-all helpers.

**State** (`src/renderer/state.js`):
- `files[]` — ordered array of `{name, path, mtime}`; order determines rename sequence
- `selectedIdxs` — `Set<number>` of selected card indices
- `currentFolder` — active directory path
- `sortMode` — `'name'` | `'date'` | `null`

**Keyboard shortcuts** (모두 `e.code` 기준 — 한글 입력 모드에서도 동작):
- `Cmd+O` — 폴더 열기
- `Cmd+S` — 이름 변경 실행
- `A` — 전체 선택
- `Cmd+Z` — Undo (되돌리기)
- `Cmd+Shift+Z` — Redo (다시하기)
- `Space` — 미리보기 on/off 토글
- `D` — 파일 탐색기 표시/숨김 토글
- `Backspace` / `Delete` — 선택 파일 삭제 팝업
- `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` — 해당 방향의 카드를 키보드 타겟으로 이동하고 단독 선택
- `Home` / `End` — 첫 카드 / 마지막 카드를 키보드 타겟으로 이동하고 단독 선택 (`grid-container` 최상단 / 최하단으로 스크롤)
- `Shift+Arrow*` / `Shift+Home` / `Shift+End` — 기존 선택 목록을 유지한 채 Finder처럼 anchor부터 새 키보드 타겟까지 범위 추가 선택

Global shortcuts are registered in `keyboard.js`. They do not fire while any `.modal-overlay` is open.

**Modals** (`modal.js` + modal HTML in `index.html`):
- All modal cancel/close buttons use `.modal-close` and `btn-ghost`.
- All modal primary buttons use `.modal-action`, `btn-success`, and the text `실행`.
- `Escape` triggers the active modal's `.modal-close`; `Enter` triggers its `.modal-action`.
- `Tab` / `Shift+Tab` is trapped inside the active modal.
- Modal titles use `.modal-titlebar`; warning/danger variants are `.modal-titlebar-warning` and `.modal-titlebar-danger`.
- Rename confirm uses the same modal for selected-only and all-image rename. The visible title switches between "선택된 이미지 저장" and "전체 이미지 저장"; selected-only mode also shows "선택된 파일들만 저장합니다".

**Undo / Redo** (`history.js`):
- 파일 순서 변경(드래그, 정렬)과 staged delete를 추적; rename, move는 파일 경로가 바뀌므로 히스토리 리셋
- Reorder 엔트리: `{ type: 'reorder', files: [...], affectedPaths: Set | null }`
- Delete 엔트리: `{ type: 'delete', files: [...], deletedEntries: [...] }`
- `affectedPaths`: undo/redo 실행 후 해당 파일들을 선택 상태로 복원하는 데 사용
- `setOnUpdate(fn)`: push/reset/undo/redo 시 자동 호출되는 콜백 등록 (버튼 상태 갱신용)
- 최대 50개 엔트리 유지
- Undo/redo orchestration lives in `history-controller.js`; actual stack operations stay in `history.js`.

**Card selection** (`selection.js`):
- Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택
- Cmd+Click — 개별 토글
- Arrow/Home/End keyboard navigation — `keyboardTargetIdx`를 이동하고 해당 카드를 단독 선택; 위/아래 이동은 현재 그리드의 실제 첫 행 카드 수로 column count를 계산
- Shift+Arrow/Home/End — Shift 확장 시작 시점의 선택 스냅샷을 보존하고, `lastSelectedIdx` anchor부터 `keyboardTargetIdx`까지의 범위를 추가 선택
- Home/End keyboard navigation — 선택 타겟 이동 후 `grid-container`를 각각 최상단/최하단으로 스크롤
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출)

**Drag-and-drop** (`drag-drop.js`):
- 그리드 내 카드 순서 재배치 (single/multi)
  - 드래그 중인 카드는 `.drag-hidden`으로 숨김; 삽입 위치에 `.card-placeholder` 표시
  - 마지막 카드 이후 빈 영역에 드롭하면 맨 끝에 삽입
  - 드롭 완료 후 이동한 파일들이 선택 상태로 유지되고 history에 push
- 사이드바 폴더로 드래그: 선택된 파일들을 해당 폴더로 이동
  - 파일명 충돌 시 rename-and-move 모달 표시
  - 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트
- Drag auto-scroll and drag ghost UI are extracted to `handlers/drag/auto-scroll.js` and `handlers/drag/ghost.js`.

**Rename** (`rename.js`):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 파일명 포맷은 `input-prefix` + 자리순번 + `input-postfix` + 확장자이다. 구분자 `_`는 자동 삽입하지 않고 입력값 자체에 포함한다.
- `input-prefix`의 HTML 기본값은 `photo_`, `input-postfix`의 HTML 기본값은 `_`이다. 접미사를 비우면 접두사와 자리순번만으로 저장한다.
- 폴더 로드 시 이미지 파일 유무와 관계없이 폴더명 뒤에 `_`를 붙여 `input-prefix`로 설정한다.
- 상단 파일명 preview, 카드별 새 이름 라벨, rename 확인 목록은 모두 `utils/file-names.js`의 `getNameFormat()`과 `buildSequentialName()`을 사용해 같은 규칙을 공유한다.
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)

**Move** (`move.js`):
- 충돌 없으면 즉시 이동
- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동
- 새 이름도 충돌이면 에러 모달
- 완료 후 history 리셋

**Delete** (`delete.js`):
- `Backspace` / `Delete` or card delete button opens the delete modal.
- If a clicked card is part of the current selection, all selected files are deleted; otherwise only that card is deleted.
- Main process stages deleted files under `app.getPath('userData')/deleted_staging/<timestamp>/`.
- Delete is undoable/redoable via `history-controller.js` + `stage-delete-files` / `restore-staged-files`.

**Thumbnail loading:** `IntersectionObserver` with `rootMargin: '400px 0px'` — lazy load as cards enter viewport. `entry.target.isConnected` 체크로 폴더 전환 중 스탈 카드 방지. Drag reorder remaps card elements via `card.dataset.filepath` to avoid re-fetching.

**Hover preview** (`image-preview.js`): 활성화 상태에서 카드 hover 시 대기 없이 전체 크기 팝업을 표시한다. 썸네일 캐시 우선 사용. 상태는 config.json에 영속. `Space`로 preview를 켤 때 커서가 카드 위에 있으면 즉시 팝업을 표시하고, 끌 때는 열린 팝업을 즉시 닫는다.

**Workspace / file explorer:** `index.html` uses semantic `section.app-workspace` above the `aside` and `main`. The top `workspace-nav` contains the file explorer visibility toggle, folder open/rename actions, preview/undo/redo controls, and sort group. The file explorer visibility toggle uses the fixed text `🗂️ (D)`, persists as `fileExplorerVisible`, and hides both `aside` and `#sidebar-resizer` via `.file-explorer-hidden`.

**Sidebar tree** (`file-explorer.js`): Lazy-load 자식 폴더. 접근 불가 폴더는 `null` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글.

**Thumbnail size** (`pinch-zoom.js`): Ctrl+scroll changes CSS `--thumb-size` in the 150–400px range. The latest size is persisted as `thumbSize` in config.json and restored on startup without showing the size hint.

**Styling:** CSS는 `src/renderer/style.css`에 집중. Dark theme, CSS variables in `:root`. macOS traffic-light은 `header`의 `-webkit-app-region: drag`. Avoid inline styles in `index.html`; use small semantic classes such as `.format-arrow`, `.empty-state-hint`, and modal title variants.

**App icon:** 소스는 `assets/icon.png` (2048×2048). `assets/icon.icns`는 10개 해상도(16px~1024px@2x)를 포함하며 electron-builder가 사용. `app.setName('Photo Renamer')`로 개발 모드 메뉴바 이름도 지정.

**Build output:** `electron-builder` targets DMG for arm64 and x64.

**Config persistence:** `app.getPath('userData')/config.json`에 `lastFolder`, `previewOn`, `sidebarWidth`, `fileExplorerVisible`, `thumbSize`, `windowState` 저장. `previewOn` and `fileExplorerVisible` default to `true`. `windowState` includes fullscreen status and normal window bounds (`x`, `y`, `width`, `height`) and is restored on startup if the saved bounds overlap an available display.
