리펙토링: 전체 구조 검토, 개선

This commit is contained in:
2026-05-15 18:36:55 +09:00
parent 75a1f9e2f5
commit a1a5672d97
10 changed files with 117 additions and 23 deletions
+67 -14
View File
@@ -15,7 +15,7 @@ There are no tests or linting configured.
## Architecture
This is a macOS Electron desktop app for batch-renaming photo files. It follows strict Electron security practices: `contextIsolation: true`, `nodeIntegration: false`.
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.
@@ -27,20 +27,27 @@ src/
│ ├── index.js # Main process entry point; creates BrowserWindow, calls registerAll()
│ └── ipc/
│ ├── index.js # registerAll(mainWindow) — delegates to folder and files modules
│ ├── folder.js # select-folder, reveal-folder
└── files.js # list-images, read-image, rename-files
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
│ # get/set-preview-on, get-home-dir, reveal-folder
│ └── files.js # list-images, read-image, rename-files,
│ # 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)
├── index.html # App shell; dark theme, CSS variables in :root, modal HTML
├── style.css # All CSS
├── renderer.js # Main renderer logic; imports state and handlers
├── state.js # Shared mutable state: files[], currentFolder, selectedIdxs, sortMode
├── renderer.js # Entry point: init, loadFolder, sortFiles, keyboard shortcuts, startup
├── ui.js # showLoading, hideLoading, setStatus, showToast
├── grid.js # renderGrid, createCard, card UI update functions, showEmptyGrid
├── rename.js # doRename + rename-conflict modal
├── move.js # handleFolderDrop + move modals + _doMoveFiles
└── handlers/
├── drag-drop.js # Card drag-and-drop reorder (single and multi-card)
├── selection.js # Rubber-band selection, Shift-click, Cmd+click
├── pinch-zoom.js # Trackpad pinch to resize thumbnails (CSS --thumb-size, 150400px)
── image-preview.js # Hover preview overlay
├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar
├── 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
@@ -50,9 +57,15 @@ 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, size}[]` filtered to image extensions, sorted by filename |
| `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 for display |
| `rename-files({folderPath, files, prefix, digits})` | invoke | Two-phase rename: temp names first, then final names to avoid collisions |
| `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) |
| `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 |
@@ -60,14 +73,54 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`:
Uses ES module imports (`type="module"`). No framework.
**Dependency direction** (no cycles):
```
renderer.js → rename.js, move.js → grid.js → handlers/*
↘ ↗
ui.js
```
**State** (`src/renderer/state.js`):
- `files[]` — ordered array of file objects; order determines rename sequence
- `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`
**Thumbnail loading:** Uses `IntersectionObserver` with `rootMargin: '400px 0px'` — images load lazily as cards enter the viewport. DOM card elements are created immediately without `src`; the observer fills in `img.src` on entry. Drag-and-drop reorder remaps existing card elements via `card.dataset.filepath` to avoid re-fetching images.
**Keyboard shortcuts:**
- `O` — 폴더 열기
- `S` — 이름 변경 실행
- `A` — 전체 선택
**Styling:** All CSS lives in `src/renderer/index.html`. Dark theme with CSS variables defined in `:root`. The macOS traffic-light buttons sit in `#titlebar` with `-webkit-app-region: drag`.
**Card selection** (`selection.js`):
- Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택
- Cmd+Click — 개별 토글
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
**Drag-and-drop** (`drag-drop.js`):
- 그리드 내: 카드 순서 재배치 (single/multi)
- 사이드바 폴더로: 선택된 파일들을 해당 폴더로 이동
- 파일명 충돌 시 rename-and-move 모달 표시
- 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트
**Rename** (`rename.js`):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
**Move** (`move.js`):
- 충돌 없으면 즉시 이동
- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동
- 새 이름도 충돌이면 에러 모달
**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`.
**Build output:** `electron-builder` targets DMG for arm64 and x64. The app icon must be at `assets/icon.icns`.
**Config persistence:** `app.getPath('userData')/config.json``lastFolder`, `previewOn` 저장.
+3 -2
View File
@@ -1,6 +1,7 @@
const { ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
@@ -12,7 +13,7 @@ function register() {
.map(f => {
const fullPath = path.join(folderPath, f);
const stat = fs.statSync(fullPath);
return { name: f, path: fullPath, mtime: stat.mtimeMs, size: stat.size };
return { name: f, path: fullPath, mtime: stat.mtimeMs };
})
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
return files;
@@ -78,7 +79,7 @@ function register() {
for (let i = 0; i < files.length; i++) {
const src = files[i].path;
const ext = path.extname(files[i].name).toLowerCase();
const tmp = path.join(folderPath, `__tmp_rename_${i}_${Date.now()}${ext}`);
const tmp = path.join(folderPath, `__tmp_${crypto.randomUUID()}${ext}`);
try {
fs.renameSync(src, tmp);
tmpMap.push({ tmp, ext, idx: i });
+1 -1
View File
@@ -53,7 +53,7 @@ function register(mainWindow) {
return { name: e.name, path: fullPath, hasChildren };
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch { return []; }
} catch { return null; }
});
ipcMain.handle('reveal-folder', (_e, folderPath) => shell.openPath(folderPath));
+5 -1
View File
@@ -5,7 +5,11 @@ contextBridge.exposeInMainWorld('api', {
listImages: (folder) => ipcRenderer.invoke('list-images', folder),
readImage: (path) => ipcRenderer.invoke('read-image', path),
renameFiles: (args) => ipcRenderer.invoke('rename-files', args),
onFullscreenChange: (callback) => ipcRenderer.on('fullscreen-change', (_e, v) => callback(v)),
onFullscreenChange: (() => {
let _cb = null;
ipcRenderer.on('fullscreen-change', (_e, v) => { if (_cb) _cb(v); });
return (callback) => { _cb = callback; };
})(),
getHomeDir: () => ipcRenderer.invoke('get-home-dir'),
getLastFolder: () => ipcRenderer.invoke('get-last-folder'),
setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p),
+2 -1
View File
@@ -31,13 +31,14 @@ export function renderGrid() {
const img = entry.target.querySelector('img.card-thumb');
if (!img || img.src) return;
const dataUrl = await window.api.readImage(entry.target.dataset.filepath);
if (dataUrl) img.src = dataUrl;
if (dataUrl && entry.target.isConnected) img.src = dataUrl;
});
}, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 });
Array.from(grid.children).forEach(card => observer.observe(card));
_cardObserver = observer;
selection.resetAnchor();
refreshCardSelection();
updateNumberBadges();
updateNewNameLabels();
+8 -1
View File
@@ -125,7 +125,14 @@ async function expandNode(nodeEl, autoExpandPath) {
if (!childrenEl.dataset.loaded) {
childrenEl.dataset.loaded = 'true';
const dirs = await window.api.listDirectories(nodeEl.dataset.path);
dirs.forEach(dir => childrenEl.appendChild(createNode(dir)));
if (dirs === null) {
const err = document.createElement('div');
err.className = 'tree-access-error';
err.textContent = '접근 불가';
childrenEl.appendChild(err);
} else {
dirs.forEach(dir => childrenEl.appendChild(createNode(dir)));
}
}
// Auto-expand toward the target path
+18 -1
View File
@@ -4,9 +4,15 @@ let _state, _cb;
let rbActive = false;
let rbStartX = 0;
let rbStartY = 0;
let _rafPending = false;
let _lastMoveEvent = null;
let lastSelectedIdx = null;
export function resetAnchor() {
lastSelectedIdx = null;
}
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
_rubberBand = rubberBand;
_grid = grid;
@@ -98,6 +104,17 @@ function _onStart(e) {
function _onMove(e) {
if (!rbActive) return;
_lastMoveEvent = e;
if (!_rafPending) {
_rafPending = true;
requestAnimationFrame(_processMove);
}
}
function _processMove() {
_rafPending = false;
const e = _lastMoveEvent;
if (!rbActive || !e) return;
const left = Math.min(rbStartX, e.clientX);
const top = Math.min(rbStartY, e.clientY);
@@ -110,11 +127,11 @@ function _onMove(e) {
});
const sel = { left, top, right: left + width, bottom: top + height };
const preserve = e.shiftKey || e.ctrlKey || e.metaKey;
Array.from(_grid.children).forEach((card, i) => {
const r = card.getBoundingClientRect();
const overlaps = !(r.right < sel.left || r.left > sel.right ||
r.bottom < sel.top || r.top > sel.bottom);
const preserve = e.shiftKey || e.ctrlKey || e.metaKey;
if (overlaps) {
_state.selectedIdxs.add(i);
} else if (!preserve) {
+5 -1
View File
@@ -35,7 +35,11 @@ export async function doRename() {
});
const conflicting = newNames.filter(n => unselectedNames.has(n));
if (conflicting.length) {
renameConflictList.innerHTML = conflicting.map(n => `<li>${n}</li>`).join('');
renameConflictList.replaceChildren(...conflicting.map(n => {
const li = document.createElement('li');
li.textContent = n;
return li;
}));
renameConflictModal.style.display = 'flex';
return;
}
+4 -1
View File
@@ -12,6 +12,8 @@ import * as dragDrop from './handlers/drag-drop.js';
import * as hoverPreview from './handlers/image-preview.js';
import * as fileExplorer from './handlers/file-explorer.js';
let _homeDir = null;
// ── DOM ──────────────────────────────────────────────
const btnOpen = document.getElementById('btn-open');
const btnSortName = document.getElementById('btn-sort-name');
@@ -48,6 +50,7 @@ window.api.onFullscreenChange((isFullscreen) => {
window.api.getHomeDir(),
window.api.getPreviewOn(),
]);
_homeDir = homeDir;
btnPreviewToggle.classList.toggle('active', previewOn);
hoverPreview.setEnabled(previewOn);
@@ -85,7 +88,7 @@ document.addEventListener('keydown', (e) => {
async function openFolder() {
const folder = await window.api.selectFolder();
if (!folder) return;
const homeDir = await window.api.getHomeDir();
const homeDir = _homeDir;
if (folder.startsWith(homeDir)) {
await fileExplorer.loadTree(homeDir, folder);
} else {
+4
View File
@@ -474,6 +474,10 @@ footer span { font-size: 11px; color: var(--muted); }
border-left: 1px solid rgba(255,255,255,.05);
margin-left: 15px;
}
.tree-access-error {
font-size: 11px; color: var(--muted); opacity: .5;
padding: 2px 8px; font-style: italic;
}
/* ── 폴더 드롭 타겟 하이라이트 ── */
.tree-row.drop-target {