From 7c2a076e41e1f2f14bd10b3529a0b0bc32be518e Mon Sep 17 00:00:00 2001 From: jisangs Date: Fri, 15 May 2026 15:26:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=EC=97=90?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/folder.js | 49 ++++++++- src/preload/index.js | 5 + src/renderer/handlers/file-explorer.js | 146 +++++++++++++++++++++++++ src/renderer/renderer.js | 44 +++++++- src/renderer/style.css | 49 +++++++++ 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 src/renderer/handlers/file-explorer.js 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%;