Files
photo-renamer/CLAUDE.md
T
2026-05-17 23:06:14 +09:00

194 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) 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()
│ └── 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-home-dir, reveal-folder
│ └── files.js # IPC handlers: list-images, read-image, rename-files,
│ # check-files-exist, move-files, stage-delete-files,
│ # restore-staged-files — delegates logic to file-utils.js
├── 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: wires up controllers, handlers, event listeners
├── folder-controller.js # createFolderController() — loadFolder, openFolder, sortFiles
├── history-controller.js # createHistoryController() — performUndo, performRedo, applySnapshot
├── keyboard.js # initKeyboardShortcuts() — modal-guard, all shortcut bindings
├── ui.js # showLoading, hideLoading, setStatus, showToast
├── grid.js # renderGrid, createCard, card UI update functions, showEmptyGrid
├── rename.js # doRename + rename-conflict modal; resets history after rename
├── move.js # handleFolderDrop + move modals + _doMoveFiles; resets history after move
├── delete.js # deleteSelected, init (card-delete btn) + staging delete flow
├── history.js # Undo/redo stack (max 50 entries); reorder + delete entry types
├── modal.js # initModalControls, openModal, closeModal, focus trap
├── utils/
│ ├── file-names.js # getExtension, clampDigits, buildSequentialName, getNameFormat
│ └── dom.js # renderTextList
└── handlers/
├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar
├── drag/
│ ├── auto-scroll.js # createAutoScroller() — rAF-based edge scroll during drag
│ └── ghost.js # createDragGhost() — floating badge during drag
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
├── selection.js # Rubber-band, Shift-click, Cmd+click, resetAnchor
├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150400px)
└── image-preview.js # Hover preview overlay with circular loading indicator
```
### 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})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names |
| `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(filePaths[])` | invoke | Moves files to userData/deleted_staging/<timestamp>/; returns `{staged, errors}` |
| `restore-staged-files(deletedEntries[])` | invoke | Restores staged files to original paths; returns `{restored, errors}` |
| `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 |
| `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 ─┐
├─ history-controller.js ├──► grid.js ──► handlers/*
├─ keyboard.js │ ↗
├─ rename.js ├──► history.js
├─ move.js ├──► ui.js
├─ delete.js │
└─ modal.js ─┘
utils/file-names.js, utils/dom.js
(imported by grid, rename, move, delete)
```
**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`
**Controllers** (`folder-controller.js`, `history-controller.js`):
- Factory functions (`createFolderController`, `createHistoryController`) receive DOM refs at creation time and return method objects.
- `folder-controller`: `loadFolder`, `openFolder`, `sortFiles`, `updateSortButtons`, `setHomeDir`
- `history-controller`: `performUndo`, `performRedo`, `updateButtons`; `updateSortButtons` injected from folder-controller
**Keyboard shortcuts** (`keyboard.js``initKeyboardShortcuts()`):
- 모달이 열려 있거나 INPUT에 포커스된 경우 단축키 전체 무시 (`_hasOpenModal()` 가드)
- 모두 `e.code` 기준 — 한글 입력 모드에서도 동작
- `O` — 폴더 열기
- `S` — 이름 변경 실행
- `A` — 전체 선택
- `Backspace` / `Delete` — 선택 파일 삭제
- `Cmd+Z` — Undo (되돌리기)
- `Cmd+Shift+Z` — Redo (다시하기)
- `P` — 미리보기 on/off 토글
**Undo / Redo** (`history.js` + `history-controller.js`):
- 파일 순서 변경(드래그, 정렬)과 삭제 작업을 추적; rename/move는 히스토리 리셋
- 엔트리 타입:
- `reorder`: `{ type, files, affectedPaths: Set | null }`
- `delete`: `{ type, files /* filesAfter */, deletedEntries: [{name, originalPath, stagingPath}] }`
- `deletedEntries`는 redo 시 stagingPath가 갱신되는 mutable 객체
- `setOnUpdate(fn)`: push/reset/undo/redo 시 자동 호출되는 콜백 등록 (버튼 상태 갱신용)
- 최대 50개 엔트리 유지
**Delete** (`delete.js`):
- 삭제 시 파일시스템에서 바로 제거하지 않고 userData/deleted_staging/으로 이동 (undo 가능)
- `deleteSelected()` — 키보드 단축키용
- `init(gridEl)` — 카드의 × 버튼 클릭 이벤트 등록
- 삭제 확인 모달 → `_doDelete()` → history에 delete 엔트리 push
**Card selection** (`selection.js`):
- Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택
- Cmd+Click — 개별 토글
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
**Drag-and-drop** (`drag-drop.js`):
- 그리드 내 카드 순서 재배치 (single/multi)
- 드래그 중인 카드는 `.drag-hidden`으로 숨김; 삽입 위치에 세로 separator 표시
- 마지막 카드 이후 빈 영역에 드롭하면 맨 끝에 삽입
- 드롭 완료 후 이동한 파일들이 선택 상태로 유지되고 history에 push
- 사이드바 폴더로 드래그: 선택된 파일들을 해당 폴더로 이동
- 파일명 충돌 시 rename-and-move 모달 표시
- 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트
- `drag/auto-scroll.js`: `createAutoScroller(container, isActive)` — 뷰포트 상하 10% 진입 시 rAF 기반 자동 스크롤
- `drag/ghost.js`: `createDragGhost(el)` — 드래그 중 floating 배지 위치/텍스트 관리
**Utilities** (`utils/`):
- `utils/file-names.js`: `getExtension(fileName, fallback?)`, `clampDigits(value, fallback?)`, `buildSequentialName(fileName, idx, {prefix, digits})`, `getNameFormat(prefixInput, digitsInput)`
- `utils/dom.js`: `renderTextList(listEl, items)``<li>` 목록을 listEl에 직접 렌더링
**Rename** (`rename.js`):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)
**Move** (`move.js`):
- 충돌 없으면 즉시 이동
- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동
- 새 이름도 충돌이면 에러 모달
- 완료 후 history 리셋
**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`): 1초 지연 후 전체 크기 팝업. 썸네일 캐시 우선 사용. 상태는 config.json에 영속.
**Sidebar tree** (`file-explorer.js`): Lazy-load 자식 폴더. 접근 불가 폴더는 `null` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글.
**Styling:** CSS는 `src/renderer/style.css`에 집중. Dark theme, CSS variables in `:root`. macOS traffic-light은 `header``-webkit-app-region: drag`.
**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` 저장.