12 KiB
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Commands
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 # Shared main-process file helpers: image listing/data URLs,
│ │ # exists check, EXDEV-safe move/copy fallback
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
│ │ # get/set-preview-on, get/set-sidebar-width,
│ │ # 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, resetAnchor
├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150–400px)
└── 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(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 |
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 sequenceselectedIdxs—Set<number>of selected card indicescurrentFolder— active directory pathsortMode—'name'|'date'|null
Keyboard shortcuts (모두 e.code 기준 — 한글 입력 모드에서도 동작):
Cmd+O— 폴더 열기Cmd+S— 이름 변경 실행A— 전체 선택Cmd+Z— Undo (되돌리기)Cmd+Shift+Z— Redo (다시하기)P— 미리보기 on/off 토글Backspace/Delete— 선택 파일 삭제 팝업
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-closeandbtn-ghost. - All modal primary buttons use
.modal-action,btn-success, and the text실행. Escapetriggers the active modal's.modal-close;Entertriggers its.modal-action.Tab/Shift+Tabis trapped inside the active modal.- Modal titles use
.modal-titlebar; warning/danger variants are.modal-titlebar-warningand.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 inhistory.js.
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으로 숨김; 삽입 위치에.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.jsandhandlers/drag/ghost.js.
Rename (rename.js):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)
Move (move.js):
- 충돌 없으면 즉시 이동
- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동
- 새 이름도 충돌이면 에러 모달
- 완료 후 history 리셋
Delete (delete.js):
Backspace/Deleteor 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): 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. 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 저장.