파일 삭제 기능 추가, undo / redo 기능에 적용
This commit is contained in:
@@ -95,8 +95,9 @@ renderer.js → rename.js, move.js → grid.js → handlers/*
|
|||||||
- `O` — 폴더 열기
|
- `O` — 폴더 열기
|
||||||
- `S` — 이름 변경 실행
|
- `S` — 이름 변경 실행
|
||||||
- `A` — 전체 선택
|
- `A` — 전체 선택
|
||||||
- `U` — Undo (되돌리기)
|
- `Cmd+Z` — Undo (되돌리기)
|
||||||
- `R` — Redo (다시하기)
|
- `Cmd+Shift+Z` — Redo (다시하기)
|
||||||
|
- `P` — 미리보기 on/off 토글
|
||||||
|
|
||||||
**Undo / Redo** (`history.js`):
|
**Undo / Redo** (`history.js`):
|
||||||
- 파일 순서 변경(드래그, 정렬)만 추적; 파일시스템 변경(rename, move)은 히스토리 리셋
|
- 파일 순서 변경(드래그, 정렬)만 추적; 파일시스템 변경(rename, move)은 히스토리 리셋
|
||||||
|
|||||||
+54
-1
@@ -1,4 +1,4 @@
|
|||||||
const { ipcMain } = require('electron');
|
const { ipcMain, app } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -73,6 +73,59 @@ function register() {
|
|||||||
return { moved, errors };
|
return { moved, errors };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 삭제 → 스테이징 폴더로 이동 (undo 가능)
|
||||||
|
ipcMain.handle('stage-delete-files', async (_event, filePaths) => {
|
||||||
|
const batchDir = path.join(app.getPath('userData'), 'deleted_staging', Date.now().toString());
|
||||||
|
fs.mkdirSync(batchDir, { recursive: true });
|
||||||
|
|
||||||
|
const staged = [];
|
||||||
|
const errors = [];
|
||||||
|
for (const fp of filePaths) {
|
||||||
|
const name = path.basename(fp);
|
||||||
|
const dest = path.join(batchDir, name);
|
||||||
|
try {
|
||||||
|
fs.renameSync(fp, dest);
|
||||||
|
staged.push({ name, originalPath: fp, stagingPath: dest });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'EXDEV') {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(fp, dest);
|
||||||
|
fs.unlinkSync(fp);
|
||||||
|
staged.push({ name, originalPath: fp, stagingPath: dest });
|
||||||
|
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
|
||||||
|
} else { errors.push(`${name}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!staged.length) try { fs.rmdirSync(batchDir); } catch {}
|
||||||
|
return { staged, errors };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 스테이징 파일 원래 위치로 복원 (undo delete)
|
||||||
|
ipcMain.handle('restore-staged-files', async (_event, deletedEntries) => {
|
||||||
|
const restored = [];
|
||||||
|
const errors = [];
|
||||||
|
for (const { name, originalPath, stagingPath } of deletedEntries) {
|
||||||
|
if (fs.existsSync(originalPath)) {
|
||||||
|
errors.push(`${name}: 같은 이름의 파일이 이미 존재합니다`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(originalPath), { recursive: true });
|
||||||
|
fs.renameSync(stagingPath, originalPath);
|
||||||
|
restored.push(originalPath);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'EXDEV') {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(stagingPath, originalPath);
|
||||||
|
fs.unlinkSync(stagingPath);
|
||||||
|
restored.push(originalPath);
|
||||||
|
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
|
||||||
|
} else { errors.push(`${name}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { restored, errors };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
|
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const pad = (n) => String(n + 1).padStart(digits, '0');
|
const pad = (n) => String(n + 1).padStart(digits, '0');
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on),
|
setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on),
|
||||||
checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names),
|
checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names),
|
||||||
moveFiles: (args) => ipcRenderer.invoke('move-files', args),
|
moveFiles: (args) => ipcRenderer.invoke('move-files', args),
|
||||||
|
stageDeleteFiles: (paths) => ipcRenderer.invoke('stage-delete-files', paths),
|
||||||
|
restoreStagedFiles: (entries) => ipcRenderer.invoke('restore-staged-files', entries),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { setStatus, showToast } from './ui.js';
|
||||||
|
import { renderGrid, showEmptyGrid } from './grid.js';
|
||||||
|
import * as history from './history.js';
|
||||||
|
|
||||||
|
const deleteModal = document.getElementById('delete-modal');
|
||||||
|
const deleteFileList = document.getElementById('delete-file-list');
|
||||||
|
const deleteCancelBtn = document.getElementById('delete-cancel');
|
||||||
|
const deleteConfirmBtn = document.getElementById('delete-confirm');
|
||||||
|
const btnRename = document.getElementById('btn-rename');
|
||||||
|
|
||||||
|
let _pendingFiles = null;
|
||||||
|
|
||||||
|
deleteCancelBtn.addEventListener('click', () => {
|
||||||
|
deleteModal.style.display = 'none';
|
||||||
|
_pendingFiles = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteConfirmBtn.addEventListener('click', async () => {
|
||||||
|
if (!_pendingFiles) return;
|
||||||
|
const files = _pendingFiles;
|
||||||
|
_pendingFiles = null;
|
||||||
|
deleteModal.style.display = 'none';
|
||||||
|
await _doDelete(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function deleteSelected() {
|
||||||
|
if (!state.selectedIdxs.size) return;
|
||||||
|
const filesToDelete = [...state.selectedIdxs].sort((a, b) => a - b)
|
||||||
|
.map(i => state.files[i]).filter(Boolean);
|
||||||
|
if (!filesToDelete.length) return;
|
||||||
|
deleteFileList.replaceChildren(...filesToDelete.map(f => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = f.name;
|
||||||
|
return li;
|
||||||
|
}));
|
||||||
|
_pendingFiles = filesToDelete;
|
||||||
|
deleteModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init(gridEl) {
|
||||||
|
gridEl.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.card-delete');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const card = btn.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
const idx = parseInt(card.dataset.idx);
|
||||||
|
|
||||||
|
let filesToDelete;
|
||||||
|
if (state.selectedIdxs.size > 0 && state.selectedIdxs.has(idx)) {
|
||||||
|
filesToDelete = [...state.selectedIdxs].sort((a, b) => a - b)
|
||||||
|
.map(i => state.files[i]).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
filesToDelete = [state.files[idx]].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filesToDelete.length) return;
|
||||||
|
|
||||||
|
deleteFileList.replaceChildren(...filesToDelete.map(f => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = f.name;
|
||||||
|
return li;
|
||||||
|
}));
|
||||||
|
_pendingFiles = filesToDelete;
|
||||||
|
deleteModal.style.display = 'flex';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doDelete(files) {
|
||||||
|
const result = await window.api.stageDeleteFiles(files.map(f => f.path));
|
||||||
|
|
||||||
|
if (result.errors.length) {
|
||||||
|
alert('일부 오류:\n' + result.errors.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedSet = new Set(result.staged.map(s => s.originalPath));
|
||||||
|
for (let i = state.files.length - 1; i >= 0; i--) {
|
||||||
|
if (stagedSet.has(state.files[i].path)) state.files.splice(i, 1);
|
||||||
|
}
|
||||||
|
state.selectedIdxs.clear();
|
||||||
|
|
||||||
|
history.pushDelete([...state.files], result.staged);
|
||||||
|
|
||||||
|
const n = result.staged.length;
|
||||||
|
if (!state.files.length) {
|
||||||
|
showEmptyGrid();
|
||||||
|
btnRename.disabled = true;
|
||||||
|
setStatus('이미지 파일 없음', false);
|
||||||
|
} else {
|
||||||
|
renderGrid();
|
||||||
|
setStatus(`${n}개 파일 삭제됨`, true);
|
||||||
|
}
|
||||||
|
if (n > 0) showToast(`${n}개 파일 삭제됨`, '#8b1a1a', '#ffd0d0');
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ export function createCard(file, idx) {
|
|||||||
checkBadge.className = 'card-check';
|
checkBadge.className = 'card-check';
|
||||||
checkBadge.textContent = '✓';
|
checkBadge.textContent = '✓';
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'card-delete';
|
||||||
|
deleteBtn.title = '삭제';
|
||||||
|
deleteBtn.textContent = '×';
|
||||||
|
deleteBtn.draggable = false;
|
||||||
|
|
||||||
// src 없이 생성 — IntersectionObserver가 뷰포트 진입 시 로딩
|
// src 없이 생성 — IntersectionObserver가 뷰포트 진입 시 로딩
|
||||||
const thumb = document.createElement('img');
|
const thumb = document.createElement('img');
|
||||||
thumb.className = 'card-thumb';
|
thumb.className = 'card-thumb';
|
||||||
@@ -78,6 +84,7 @@ export function createCard(file, idx) {
|
|||||||
body.appendChild(newNameEl);
|
body.appendChild(newNameEl);
|
||||||
card.appendChild(numBadge);
|
card.appendChild(numBadge);
|
||||||
card.appendChild(checkBadge);
|
card.appendChild(checkBadge);
|
||||||
|
card.appendChild(deleteBtn);
|
||||||
card.appendChild(thumb);
|
card.appendChild(thumb);
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
|
|||||||
|
|
||||||
// 카드에 부착할 클릭 핸들러 (this = card element)
|
// 카드에 부착할 클릭 핸들러 (this = card element)
|
||||||
export function onCardClick(e) {
|
export function onCardClick(e) {
|
||||||
|
if (e.target.closest('.card-delete')) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = parseInt(this.dataset.idx);
|
const idx = parseInt(this.dataset.idx);
|
||||||
const isCtrl = e.ctrlKey || e.metaKey;
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
|
|||||||
+37
-10
@@ -1,35 +1,62 @@
|
|||||||
|
// History entries
|
||||||
|
// reorder: { type:'reorder', files:[...], affectedPaths: Set|null }
|
||||||
|
// delete: { type:'delete', files:[...] /* filesAfter */,
|
||||||
|
// deletedEntries:[{name, originalPath, stagingPath}] /* mutable */ }
|
||||||
|
|
||||||
const MAX = 50;
|
const MAX = 50;
|
||||||
let _stack = [];
|
let _stack = [];
|
||||||
let _cursor = -1;
|
let _cursor = -1;
|
||||||
let _onUpdate = null;
|
let _onUpdate = null;
|
||||||
|
|
||||||
export function setOnUpdate(fn) {
|
export function setOnUpdate(fn) { _onUpdate = fn; }
|
||||||
_onUpdate = fn;
|
|
||||||
|
// ── Push ─────────────────────────────────────────
|
||||||
|
export function pushReorder(files, affectedPaths = null) {
|
||||||
|
_truncate();
|
||||||
|
_stack.push({
|
||||||
|
type: 'reorder',
|
||||||
|
files: [...files],
|
||||||
|
affectedPaths: affectedPaths ? new Set(affectedPaths) : null,
|
||||||
|
});
|
||||||
|
_finalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function push(files, affectedPaths = null) {
|
export function pushDelete(filesAfter, deletedEntries) {
|
||||||
_stack = _stack.slice(0, _cursor + 1);
|
_truncate();
|
||||||
_stack.push({ files: [...files], affectedPaths: affectedPaths ? new Set(affectedPaths) : null });
|
_stack.push({ type: 'delete', files: [...filesAfter], deletedEntries });
|
||||||
|
_finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _truncate() { _stack = _stack.slice(0, _cursor + 1); }
|
||||||
|
function _finalize() {
|
||||||
if (_stack.length > MAX) _stack.shift();
|
if (_stack.length > MAX) _stack.shift();
|
||||||
_cursor = _stack.length - 1;
|
_cursor = _stack.length - 1;
|
||||||
_onUpdate?.();
|
_onUpdate?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns { files, affectedPaths } where affectedPaths are from the entry being undone
|
// ── Undo / Redo ───────────────────────────────────
|
||||||
|
// Returns { entry, targetFiles }
|
||||||
|
// entry — the entry being undone/redone (for type-specific side-effects)
|
||||||
|
// targetFiles — the files[] to restore in state
|
||||||
export function undo() {
|
export function undo() {
|
||||||
if (!canUndo()) return null;
|
if (!canUndo()) return null;
|
||||||
const affectedPaths = _stack[_cursor].affectedPaths;
|
const entry = _stack[_cursor]; // operation being undone
|
||||||
--_cursor;
|
--_cursor;
|
||||||
_onUpdate?.();
|
_onUpdate?.();
|
||||||
return { files: [..._stack[_cursor].files], affectedPaths };
|
return { entry, targetFiles: [..._stack[_cursor].files] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns { files, affectedPaths } from the entry being redone
|
|
||||||
export function redo() {
|
export function redo() {
|
||||||
if (!canRedo()) return null;
|
if (!canRedo()) return null;
|
||||||
++_cursor;
|
++_cursor;
|
||||||
_onUpdate?.();
|
_onUpdate?.();
|
||||||
return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths };
|
const entry = _stack[_cursor];
|
||||||
|
return { entry, targetFiles: [...entry.files] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek at the next redo entry without moving cursor (for pre-processing)
|
||||||
|
export function peekRedo() {
|
||||||
|
return canRedo() ? _stack[_cursor + 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canUndo() { return _cursor > 0; }
|
export function canUndo() { return _cursor > 0; }
|
||||||
|
|||||||
@@ -102,6 +102,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="delete-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-box">
|
||||||
|
<p class="modal-msg">아래 파일을 삭제합니다.</p>
|
||||||
|
<ul id="delete-file-list" class="modal-conflict-list"></ul>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="delete-cancel" class="btn btn-ghost">취소</button>
|
||||||
|
<button id="delete-confirm" class="btn btn-warning">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="move-error-modal" class="modal-overlay" style="display:none;">
|
<div id="move-error-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<p class="modal-msg">새로운 이름의 파일도 존재합니다.</p>
|
<p class="modal-msg">새로운 이름의 파일도 존재합니다.</p>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ async function _doMoveFiles(targetPath, files) {
|
|||||||
state.files.push(...newList);
|
state.files.push(...newList);
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
history.reset();
|
history.reset();
|
||||||
history.push(state.files);
|
history.pushReorder(state.files);
|
||||||
if (!newList.length) {
|
if (!newList.length) {
|
||||||
showEmptyGrid();
|
showEmptyGrid();
|
||||||
btnRename.disabled = true;
|
btnRename.disabled = true;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function doRename() {
|
|||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
history.reset();
|
history.reset();
|
||||||
history.push(state.files);
|
history.pushReorder(state.files);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
alert('오류 발생: ' + e.message);
|
alert('오류 발생: ' + e.message);
|
||||||
|
|||||||
+64
-19
@@ -7,6 +7,7 @@ import {
|
|||||||
} from './grid.js';
|
} from './grid.js';
|
||||||
import { doRename } from './rename.js';
|
import { doRename } from './rename.js';
|
||||||
import { handleFolderDrop } from './move.js';
|
import { handleFolderDrop } from './move.js';
|
||||||
|
import * as deleteHandler from './delete.js';
|
||||||
import * as pinchZoom from './handlers/pinch-zoom.js';
|
import * as pinchZoom from './handlers/pinch-zoom.js';
|
||||||
import * as selection from './handlers/selection.js';
|
import * as selection from './handlers/selection.js';
|
||||||
import * as dragDrop from './handlers/drag-drop.js';
|
import * as dragDrop from './handlers/drag-drop.js';
|
||||||
@@ -39,13 +40,14 @@ history.setOnUpdate(updateHistoryButtons);
|
|||||||
|
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels,
|
refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels,
|
||||||
pushHistory: (affectedPaths) => history.push(state.files, affectedPaths),
|
pushHistory: (affectedPaths) => history.pushReorder(state.files, affectedPaths),
|
||||||
};
|
};
|
||||||
|
|
||||||
pinchZoom.init({ gridContainer, thumbSizeHint });
|
pinchZoom.init({ gridContainer, thumbSizeHint });
|
||||||
selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
||||||
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
|
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
|
||||||
hoverPreview.init();
|
hoverPreview.init();
|
||||||
|
deleteHandler.init(grid);
|
||||||
fileExplorer.init(fileExplorerContainer, loadFolder);
|
fileExplorer.init(fileExplorerContainer, loadFolder);
|
||||||
|
|
||||||
window.api.onFullscreenChange((isFullscreen) => {
|
window.api.onFullscreenChange((isFullscreen) => {
|
||||||
@@ -93,8 +95,10 @@ document.addEventListener('keydown', (e) => {
|
|||||||
refreshCardSelection();
|
refreshCardSelection();
|
||||||
updateSelCount();
|
updateSelCount();
|
||||||
}
|
}
|
||||||
if (e.code === 'KeyU') performUndo();
|
if (e.metaKey && !e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performUndo(); }
|
||||||
if (e.code === 'KeyR') performRedo();
|
if (e.metaKey && e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performRedo(); }
|
||||||
|
if (e.code === 'KeyP') btnPreviewToggle.click();
|
||||||
|
if (e.code === 'Backspace' || e.code === 'Delete') deleteHandler.deleteSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
||||||
@@ -145,7 +149,7 @@ async function loadFolder(folder) {
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
btnRename.disabled = false;
|
btnRename.disabled = false;
|
||||||
history.reset();
|
history.reset();
|
||||||
history.push(state.files);
|
history.pushReorder(state.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 정렬 ─────────────────────────────────────────
|
// ── 정렬 ─────────────────────────────────────────
|
||||||
@@ -158,7 +162,7 @@ async function sortFiles(mode) {
|
|||||||
updateSortButtons();
|
updateSortButtons();
|
||||||
await renderGrid();
|
await renderGrid();
|
||||||
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
||||||
history.push(state.files);
|
history.pushReorder(state.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSortButtons() {
|
function updateSortButtons() {
|
||||||
@@ -167,43 +171,84 @@ function updateSortButtons() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Undo / Redo ───────────────────────────────────
|
// ── Undo / Redo ───────────────────────────────────
|
||||||
|
let _historyBusy = false;
|
||||||
|
|
||||||
function updateHistoryButtons() {
|
function updateHistoryButtons() {
|
||||||
btnUndo.disabled = !history.canUndo();
|
btnUndo.disabled = !history.canUndo();
|
||||||
btnRedo.disabled = !history.canRedo();
|
btnRedo.disabled = !history.canRedo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function performUndo() {
|
async function performUndo() {
|
||||||
if (!history.canUndo()) {
|
if (_historyBusy || !history.canUndo()) {
|
||||||
showToast('더 이상 되돌릴 수 없습니다');
|
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_applyHistorySnapshot(history.undo());
|
_historyBusy = true;
|
||||||
|
try {
|
||||||
|
const { entry, targetFiles } = history.undo();
|
||||||
|
if (entry.type === 'delete') {
|
||||||
|
const { restored, errors } = await window.api.restoreStagedFiles(entry.deletedEntries);
|
||||||
|
if (errors.length) showToast('일부 파일 복원 실패: ' + errors[0]);
|
||||||
|
_applyHistorySnapshot(targetFiles, new Set(restored));
|
||||||
|
if (restored.length) setStatus(`${restored.length}개 파일 복원됨`, true);
|
||||||
|
} else {
|
||||||
|
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_historyBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function performRedo() {
|
async function performRedo() {
|
||||||
if (!history.canRedo()) {
|
if (_historyBusy || !history.canRedo()) {
|
||||||
showToast('마지막 작업 상태입니다');
|
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_applyHistorySnapshot(history.redo());
|
_historyBusy = true;
|
||||||
|
try {
|
||||||
|
const nextEntry = history.peekRedo();
|
||||||
|
if (nextEntry.type === 'delete') {
|
||||||
|
const pathsToRedo = nextEntry.deletedEntries.map(e => e.originalPath);
|
||||||
|
const { staged, errors } = await window.api.stageDeleteFiles(pathsToRedo);
|
||||||
|
if (errors.length) showToast('일부 파일 삭제 실패: ' + errors[0]);
|
||||||
|
const stagedByPath = new Map(staged.map(s => [s.originalPath, s.stagingPath]));
|
||||||
|
nextEntry.deletedEntries.forEach(e => {
|
||||||
|
if (stagedByPath.has(e.originalPath)) e.stagingPath = stagedByPath.get(e.originalPath);
|
||||||
|
});
|
||||||
|
const { targetFiles } = history.redo();
|
||||||
|
_applyHistorySnapshot(targetFiles, null);
|
||||||
|
if (staged.length) setStatus(`${staged.length}개 파일 삭제됨`, true);
|
||||||
|
} else {
|
||||||
|
const { entry, targetFiles } = history.redo();
|
||||||
|
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_historyBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _applyHistorySnapshot({ files, affectedPaths }) {
|
function _applyHistorySnapshot(targetFiles, affectedPaths = null) {
|
||||||
state.files.length = 0;
|
state.files.length = 0;
|
||||||
state.files.push(...files);
|
state.files.push(...targetFiles);
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
|
|
||||||
if (affectedPaths) {
|
if (affectedPaths && affectedPaths.size > 0) {
|
||||||
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
|
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
|
||||||
affectedPaths.forEach(path => {
|
affectedPaths.forEach(p => {
|
||||||
const idx = pathToIdx.get(path);
|
const idx = pathToIdx.get(p);
|
||||||
if (idx !== undefined) state.selectedIdxs.add(idx);
|
if (idx !== undefined) state.selectedIdxs.add(idx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
state.sortMode = null;
|
state.sortMode = null;
|
||||||
updateSortButtons();
|
updateSortButtons();
|
||||||
renderGrid();
|
if (!state.files.length) {
|
||||||
|
showEmptyGrid();
|
||||||
|
btnRename.disabled = true;
|
||||||
|
} else {
|
||||||
|
renderGrid();
|
||||||
|
btnRename.disabled = false;
|
||||||
|
}
|
||||||
updateHistoryButtons();
|
updateHistoryButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,6 +307,19 @@ main {
|
|||||||
}
|
}
|
||||||
.card.selected .card-check { opacity: 1; }
|
.card.selected .card-check { opacity: 1; }
|
||||||
|
|
||||||
|
.card-delete {
|
||||||
|
position: absolute; top: 8px; right: 8px;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; border: none;
|
||||||
|
background: rgba(200, 40, 40, 0.88); color: #fff;
|
||||||
|
font-size: 16px; line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; padding: 0;
|
||||||
|
opacity: 0; transition: opacity .15s, transform .1s;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.card:hover .card-delete { opacity: 1; }
|
||||||
|
.card-delete:hover { background: rgb(210, 30, 30); transform: scale(1.12); }
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-size: 11px; color: var(--muted);
|
font-size: 11px; color: var(--muted);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
|||||||
Reference in New Issue
Block a user