diff --git a/src/main/ipc/folder.js b/src/main/ipc/folder.js
index d347c70..081d2aa 100644
--- a/src/main/ipc/folder.js
+++ b/src/main/ipc/folder.js
@@ -1,4 +1,16 @@
-const { ipcMain, dialog } = require('electron');
+const { ipcMain, dialog, app, shell } = require('electron');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+
+const configPath = path.join(app.getPath('userData'), 'config.json');
+
+function readConfig() {
+ try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; }
+}
+function writeConfig(data) {
+ try { fs.writeFileSync(configPath, JSON.stringify(data, null, 2)); } catch {}
+}
function register(mainWindow) {
ipcMain.handle('select-folder', async () => {
@@ -10,6 +22,41 @@ function register(mainWindow) {
return result.filePaths[0];
});
+ ipcMain.handle('get-home-dir', () => os.homedir());
+
+ ipcMain.handle('get-last-folder', () => {
+ const cfg = readConfig();
+ if (cfg.lastFolder) {
+ try { fs.accessSync(cfg.lastFolder); return cfg.lastFolder; } catch {}
+ }
+ return null;
+ });
+
+ ipcMain.handle('set-last-folder', (_e, folderPath) => {
+ const cfg = readConfig();
+ cfg.lastFolder = folderPath;
+ writeConfig(cfg);
+ });
+
+ ipcMain.handle('list-directories', (_e, folderPath) => {
+ try {
+ const entries = fs.readdirSync(folderPath, { withFileTypes: true });
+ return entries
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
+ .map(e => {
+ const fullPath = path.join(folderPath, e.name);
+ let hasChildren = false;
+ try {
+ hasChildren = fs.readdirSync(fullPath, { withFileTypes: true })
+ .some(c => c.isDirectory() && !c.name.startsWith('.'));
+ } catch {}
+ return { name: e.name, path: fullPath, hasChildren };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ } catch { return []; }
+ });
+
+ ipcMain.handle('reveal-folder', (_e, folderPath) => shell.openPath(folderPath));
}
module.exports = { register };
\ No newline at end of file
diff --git a/src/preload/index.js b/src/preload/index.js
index 76d8807..ac29c70 100644
--- a/src/preload/index.js
+++ b/src/preload/index.js
@@ -6,4 +6,9 @@ contextBridge.exposeInMainWorld('api', {
readImage: (path) => ipcRenderer.invoke('read-image', path),
renameFiles: (args) => ipcRenderer.invoke('rename-files', args),
onFullscreenChange: (callback) => ipcRenderer.on('fullscreen-change', (_e, v) => callback(v)),
+ getHomeDir: () => ipcRenderer.invoke('get-home-dir'),
+ getLastFolder: () => ipcRenderer.invoke('get-last-folder'),
+ setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p),
+ listDirectories: (p) => ipcRenderer.invoke('list-directories', p),
+ revealFolder: (p) => ipcRenderer.invoke('reveal-folder', p),
});
diff --git a/src/renderer/handlers/file-explorer.js b/src/renderer/handlers/file-explorer.js
new file mode 100644
index 0000000..c3a5607
--- /dev/null
+++ b/src/renderer/handlers/file-explorer.js
@@ -0,0 +1,146 @@
+// Directory tree for the sidebar file-explorer pane.
+// Lazy-loads children when a node is expanded. Tracks one selected path.
+
+let _onSelect = null; // callback(folderPath)
+let _selectedPath = null;
+
+const ICON_CLOSED = ``;
+const ICON_OPEN = ``;
+const ICON_HOME = ``;
+
+export function init(container, onSelect) {
+ _onSelect = onSelect;
+ container.innerHTML = '';
+ container.classList.add('tree-root');
+}
+
+export async function loadTree(rootPath, selectedPath) {
+ _selectedPath = selectedPath;
+ const container = document.querySelector('.file-explorer');
+ if (!container) return;
+ container.innerHTML = '';
+
+ const rootNode = createNode({
+ name: rootPath.split('/').pop() || rootPath,
+ path: rootPath,
+ hasChildren: true,
+ isRoot: true,
+ });
+ container.appendChild(rootNode);
+ await expandNode(rootNode, selectedPath);
+}
+
+function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
+ const wrap = document.createElement('div');
+ wrap.className = 'tree-node';
+ wrap.dataset.path = nodePath;
+ wrap.dataset.expanded = 'false';
+ wrap.dataset.hasChildren = hasChildren ? 'true' : 'false';
+ wrap.dataset.isRoot = isRoot ? 'true' : 'false';
+
+ const row = document.createElement('div');
+ row.className = 'tree-row';
+
+ const chevron = document.createElement('span');
+ chevron.className = 'tree-chevron';
+ chevron.innerHTML = hasChildren
+ ? ``
+ : '';
+
+ const icon = document.createElement('span');
+ icon.className = 'tree-icon';
+ icon.innerHTML = isRoot ? ICON_HOME : ICON_CLOSED;
+
+ const label = document.createElement('span');
+ label.className = 'tree-label';
+ label.textContent = name;
+ label.title = nodePath;
+
+ row.appendChild(chevron);
+ row.appendChild(icon);
+ row.appendChild(label);
+ wrap.appendChild(row);
+
+ if (hasChildren) {
+ const children = document.createElement('div');
+ children.className = 'tree-children';
+ wrap.appendChild(children);
+ }
+
+ row.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectNode(wrap);
+ if (hasChildren) toggleNode(wrap);
+ });
+
+ return wrap;
+}
+
+function selectNode(nodeEl) {
+ const path = nodeEl.dataset.path;
+ document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
+ nodeEl.querySelector(':scope > .tree-row').classList.add('active');
+ _selectedPath = path;
+ if (_onSelect) _onSelect(path);
+}
+
+async function toggleNode(nodeEl) {
+ const expanded = nodeEl.dataset.expanded === 'true';
+ if (expanded) {
+ collapseNode(nodeEl);
+ } else {
+ await expandNode(nodeEl, null);
+ }
+}
+
+function collapseNode(nodeEl) {
+ nodeEl.dataset.expanded = 'false';
+ const row = nodeEl.querySelector(':scope > .tree-row');
+ row.querySelector('.tree-chevron').classList.remove('open');
+ row.querySelector('.tree-icon').innerHTML = ICON_CLOSED;
+ const children = nodeEl.querySelector(':scope > .tree-children');
+ if (children) children.style.display = 'none';
+}
+
+async function expandNode(nodeEl, autoExpandPath) {
+ nodeEl.dataset.expanded = 'true';
+ const row = nodeEl.querySelector(':scope > .tree-row');
+ row.querySelector('.tree-chevron').classList.add('open');
+ row.querySelector('.tree-icon').innerHTML = nodeEl.dataset.isRoot === 'true' ? ICON_HOME : ICON_OPEN;
+
+ const childrenEl = nodeEl.querySelector(':scope > .tree-children');
+ if (!childrenEl) return;
+ childrenEl.style.display = '';
+
+ // Load children if not yet loaded
+ if (!childrenEl.dataset.loaded) {
+ childrenEl.dataset.loaded = 'true';
+ const dirs = await window.api.listDirectories(nodeEl.dataset.path);
+ dirs.forEach(dir => {
+ const child = createNode(dir);
+ childrenEl.appendChild(child);
+ });
+ }
+
+ // Auto-expand path to target
+ if (autoExpandPath && autoExpandPath !== nodeEl.dataset.path) {
+ const childNodes = childrenEl.querySelectorAll(':scope > .tree-node');
+ for (const child of childNodes) {
+ if (autoExpandPath === child.dataset.path || autoExpandPath.startsWith(child.dataset.path + '/')) {
+ await expandNode(child, autoExpandPath);
+ break;
+ }
+ }
+ }
+
+ if (autoExpandPath === nodeEl.dataset.path) {
+ selectNode(nodeEl);
+ }
+}
+
+export function setSelectedPath(folderPath) {
+ _selectedPath = folderPath;
+ document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
+ const node = document.querySelector(`.tree-node[data-path="${CSS.escape(folderPath)}"]`);
+ if (node) node.querySelector(':scope > .tree-row').classList.add('active');
+}
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js
index eb27947..862d63f 100644
--- a/src/renderer/renderer.js
+++ b/src/renderer/renderer.js
@@ -3,6 +3,7 @@ import * as pinchZoom from './handlers/pinch-zoom.js';
import * as selection from './handlers/selection.js';
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 _cardObserver = null;
@@ -43,10 +44,24 @@ selection.init({ gridContainer, rubberBand, grid, state, callbacks });
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks });
hoverPreview.init();
+const fileExplorerContainer = document.querySelector('.file-explorer');
+fileExplorer.init(fileExplorerContainer, loadFolder);
+
window.api.onFullscreenChange((isFullscreen) => {
document.body.classList.toggle('fullscreen', isFullscreen);
});
+// 앱 시작 시 마지막 폴더 또는 홈 디렉토리 로드
+(async () => {
+ const [lastFolder, homeDir] = await Promise.all([
+ window.api.getLastFolder(),
+ window.api.getHomeDir(),
+ ]);
+ const target = lastFolder || homeDir;
+ await fileExplorer.loadTree(homeDir, target);
+ await loadFolder(target);
+})();
+
// ── 버튼 이벤트 ──────────────────────────────────
inputPrefix.addEventListener('input', updatePreview);
inputDigits.addEventListener('input', updatePreview);
@@ -65,20 +80,45 @@ btnSortName.addEventListener('click', () => sortFiles('name'));
btnSortDate.addEventListener('click', () => sortFiles('date'));
btnRename.addEventListener('click', doRename);
-// ── 폴더 열기 ────────────────────────────────────
+// ── 폴더 열기 (다이얼로그) ───────────────────────
async function openFolder() {
const folder = await window.api.selectFolder();
+ if (!folder) return;
+ const homeDir = await window.api.getHomeDir();
+ // 선택한 폴더가 홈 디렉토리 하위인지 확인해서 트리 업데이트
+ if (folder.startsWith(homeDir)) {
+ await fileExplorer.loadTree(homeDir, folder);
+ } else {
+ await fileExplorer.loadTree(folder, folder);
+ }
+ await loadFolder(folder);
+}
+
+// ── 폴더 로드 (트리 클릭 또는 다이얼로그 공통) ──
+async function loadFolder(folder) {
if (!folder) return;
state.currentFolder = folder;
dirPath.textContent = folder;
+ await window.api.setLastFolder(folder);
+ fileExplorer.setSelectedPath(folder);
+
showLoading('이미지 목록 불러오는 중...');
const list = await window.api.listImages(folder);
hideLoading();
+
if (!list.length) {
+ state.files.length = 0;
+ state.selectedIdxs.clear();
+ grid.innerHTML = '';
+ grid.style.display = 'none';
+ emptyState.style.display = '';
+ fileCount.textContent = '0개 파일';
+ updateSelCount();
+ btnRename.disabled = true;
setStatus('이미지 파일 없음', false);
- showToast('⚠️ 이미지 파일이 없습니다', '#f76a6a', '#0a0a0a');
return;
}
+
state.files.length = 0;
state.files.push(...list);
state.selectedIdxs.clear();
diff --git a/src/renderer/style.css b/src/renderer/style.css
index d245652..8740f68 100644
--- a/src/renderer/style.css
+++ b/src/renderer/style.css
@@ -423,6 +423,55 @@ footer span { font-size: 11px; color: var(--muted); }
object-fit: contain;
}
+/* ── 파일 탐색기 트리 ── */
+.tree-root { padding: 6px 0; }
+
+.tree-node { display: flex; flex-direction: column; }
+
+.tree-row {
+ display: flex; align-items: center; gap: 5px;
+ padding: 3px 8px 3px 0;
+ border-radius: 6px;
+ cursor: pointer;
+ user-select: none;
+ color: var(--text);
+ font-size: 12.5px;
+ transition: background .1s;
+ position: relative;
+}
+.tree-row:hover { background: rgba(255,255,255,.05); }
+.tree-row.active {
+ background: rgba(124,106,247,.18);
+ color: #d0c8ff;
+}
+
+.tree-chevron {
+ width: 16px; height: 16px;
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0; color: var(--muted);
+ transition: transform .15s;
+}
+.tree-chevron.open { transform: rotate(90deg); }
+.tree-chevron svg { display: block; }
+
+.tree-icon {
+ width: 16px; height: 16px;
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0;
+}
+.tree-icon svg { display: block; }
+
+.tree-label {
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ line-height: 1.4;
+}
+
+.tree-children {
+ padding-left: 16px;
+ border-left: 1px solid rgba(255,255,255,.05);
+ margin-left: 15px;
+}
+
/* ── 토스트 ── */
#toast {
position: fixed; bottom: 40px; left: 50%;