Files

15 KiB
Raw Permalink Blame History

AGENTS.md

This file provides guidance to Claude Code (claude.ai/code) and Codex 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()
│   ├── 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 340500px width
        ├── selection.js      # Rubber-band, Shift-click, Cmd+click, keyboard navigation, resetAnchor
        ├── pinch-zoom.js     # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150400px)
        └── 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
  • selectedIdxsSet<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.jsgetNameFormat()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 150400px 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.jsonlastFolder, 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.