From 3dff4700443d1951039ea3217138de3b286f85c0 Mon Sep 17 00:00:00 2001 From: jung-geun Date: Sat, 9 May 2026 15:41:08 +0900 Subject: [PATCH] =?UTF-8?q?src/main:=20=ED=8A=B8=EB=A0=88=EC=9D=B4/?= =?UTF-8?q?=EA=B0=90=EC=8B=9C=EC=9E=90/=EC=A0=80=EC=9E=A5=EC=86=8C/IPC/?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20(Electron=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tray.ts: 트레이 아이콘, 팝오버 BrowserWindow 토글, 컨텍스트 메뉴. watcher.ts: chokidar 감시, dedup TTL 2s, auto/manual 모드, 글로벌 일시정지. store.ts: userData/store.json 영속화 (watchedDirs/settings/undoLog). ipc.ts: 18개 IPC 채널 핸들러. notifier.ts: interval 기반 알림 배치 처리 (알림 폭주 방지). settings-window.ts: 설정창 BrowserWindow 라이프사이클. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 37 ++++++++ src/main/ipc.ts | 126 +++++++++++++++++++++++++++ src/main/nanoid.ts | 5 ++ src/main/notifier.ts | 68 +++++++++++++++ src/main/settings-window.ts | 59 +++++++++++++ src/main/store.ts | 94 ++++++++++++++++++++ src/main/tray.ts | 111 ++++++++++++++++++++++++ src/main/watcher.ts | 165 ++++++++++++++++++++++++++++++++++++ 8 files changed, 665 insertions(+) create mode 100644 src/main/index.ts create mode 100644 src/main/ipc.ts create mode 100644 src/main/nanoid.ts create mode 100644 src/main/notifier.ts create mode 100644 src/main/settings-window.ts create mode 100644 src/main/store.ts create mode 100644 src/main/tray.ts create mode 100644 src/main/watcher.ts diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..643a5be --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,37 @@ +import { app } from 'electron'; +import { createTray, destroyTray } from './tray'; +import { openSettingsWindow } from './settings-window'; +import { registerIpcHandlers } from './ipc'; +import * as store from './store'; +import * as watcher from './watcher'; + +// 단일 인스턴스 강제 +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} + +app.whenReady().then(async () => { + registerIpcHandlers(); + createTray(); + + // 저장된 디렉토리 감시 복원 + const dirs = await store.getDirs(); + for (const dir of dirs.filter((d) => d.enabled)) { + watcher.startDir(dir).catch(console.error); + } + + // 감시 디렉토리가 없으면 설정창 자동 오픈 (첫 실행 안내) + if (dirs.length === 0) { + openSettingsWindow(); + } +}); + +app.on('window-all-closed', () => { + // 트레이 앱은 윈도우를 모두 닫아도 종료하지 않음 +}); + +app.on('will-quit', async () => { + destroyTray(); + await watcher.stopAll(); +}); diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..a78280f --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,126 @@ +import { ipcMain, dialog, app, BrowserWindow } from 'electron'; +import { randomUUID } from 'crypto'; +import { scan } from '../core/scanner'; +import { normalizeEntry } from '../core/normalizer'; +import { nanoid } from './nanoid'; +import * as store from './store'; +import * as watcher from './watcher'; +import type { WatchedDir, UndoEntry } from '../core/types'; + +export function registerIpcHandlers(): void { + // ── 디렉토리 선택 ── + ipcMain.handle('dialog:selectDirectory', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] }); + return result.canceled ? null : result.filePaths[0]; + }); + + // ── 디렉토리 CRUD ── + ipcMain.handle('dirs:list', async () => store.getDirs()); + + ipcMain.handle('dirs:add', async (_e, dirPath: string) => { + const settings = await store.getSettings(); + const dir: WatchedDir = { + id: randomUUID(), + path: dirPath, + recursive: false, + mode: settings.defaultMode, + enabled: true, + customRanges: [], + }; + await store.addDir(dir); + await watcher.startDir(dir); + return dir; + }); + + ipcMain.handle('dirs:update', async (_e, id: string, patch: Partial) => { + await store.updateDir(id, patch); + // 모드/recursive 변경 시 재시작 + if ('mode' in patch || 'recursive' in patch || 'enabled' in patch || 'customRanges' in patch) { + await watcher.stopDir(id); + const dirs = await store.getDirs(); + const dir = dirs.find((d) => d.id === id); + if (dir?.enabled) await watcher.startDir(dir); + } + }); + + ipcMain.handle('dirs:remove', async (_e, id: string) => { + await watcher.stopDir(id); + await store.removeDir(id); + }); + + // ── Dry-run 스캔 ── + ipcMain.handle('dirs:scan', async (_e, id: string) => { + const dirs = await store.getDirs(); + const dir = dirs.find((d) => d.id === id); + if (!dir) return []; + return scan(dir.path, dir.recursive, { customRanges: dir.customRanges }); + }); + + // ── 수동 모드 대기 큐 적용 ── + ipcMain.handle('dirs:applyQueue', async (_e, id: string) => { + const dirs = await store.getDirs(); + const dir = dirs.find((d) => d.id === id); + if (!dir) return []; + return watcher.applyManualQueue(id, dir); + }); + + ipcMain.handle('dirs:pendingQueue', async (_e, id: string) => { + return watcher.getPendingQueue(id); + }); + + // ── 감시 일시정지/재개/상태 ── + ipcMain.handle('watcher:status', () => ({ paused: watcher.isGloballyPaused() })); + + ipcMain.handle('watcher:pauseAll', async () => { + await watcher.pauseAll(); + }); + + ipcMain.handle('watcher:resumeAll', async () => { + const dirs = await store.getDirs(); + await watcher.resumeAll(dirs); + }); + + // ── Undo ── + ipcMain.handle('undo:list', async () => store.getUndoLog()); + + ipcMain.handle('undo:revertEntry', async (_e, entry: UndoEntry) => { + try { + const result = await normalizeEntry(entry.newPath, 'file'); + // 실제 되돌리기: newPath → oldPath + const fs = await import('fs/promises'); + await fs.rename(entry.newPath, entry.oldPath); + await store.markUndoReverted([entry.id]); + return { success: true, result }; + } catch (err) { + return { success: false, error: String(err) }; + } + }); + + ipcMain.handle('undo:revertLastBatch', async () => { + const log = await store.getUndoLog(); + if (!log.length) return []; + const lastTs = log[log.length - 1].ts; + // 마지막 5초 이내의 항목을 한 배치로 묶음 + const batch = log.filter((e) => !e.reverted && lastTs - e.ts < 5000); + const fs = await import('fs/promises'); + const results = []; + for (const entry of [...batch].reverse()) { + try { + await fs.rename(entry.newPath, entry.oldPath); + results.push({ ...entry, success: true }); + } catch (err) { + results.push({ ...entry, success: false, error: String(err) }); + } + } + await store.markUndoReverted(batch.map((e) => e.id)); + return results; + }); + + // ── 설정 ── + ipcMain.handle('settings:get', async () => store.getSettings()); + ipcMain.handle('settings:update', async (_e, patch) => store.updateSettings(patch)); + + // ── 앱 정보 ── + ipcMain.handle('app:version', () => app.getVersion()); +} diff --git a/src/main/nanoid.ts b/src/main/nanoid.ts new file mode 100644 index 0000000..c310d1a --- /dev/null +++ b/src/main/nanoid.ts @@ -0,0 +1,5 @@ +import { randomBytes } from 'crypto'; + +export function nanoid(): string { + return randomBytes(12).toString('base64url'); +} diff --git a/src/main/notifier.ts b/src/main/notifier.ts new file mode 100644 index 0000000..b08903a --- /dev/null +++ b/src/main/notifier.ts @@ -0,0 +1,68 @@ +import { Notification } from 'electron'; +import * as store from './store'; + +interface Batch { + count: number; + timer: ReturnType; +} + +// dirPath → 현재 진행 중인 배치 +const batches = new Map(); + +/** + * 자동 변환 알림을 배치에 추가한다. + * 첫 rename이 들어오면 타이머를 시작하고, 인터벌이 끝나면 + * 그동안 쌓인 총 건수를 담은 알림을 한 번만 발송한다. + */ +export async function queueRenamedNotification(dirPath: string): Promise { + const settings = await store.getSettings(); + if (!settings.notificationsEnabled) return; + + const intervalMs = (settings.notificationIntervalSecs ?? 30) * 1000; + + let batch = batches.get(dirPath); + if (!batch) { + const timer = setTimeout(() => flushBatch(dirPath), intervalMs); + batch = { count: 0, timer }; + batches.set(dirPath, batch); + } + batch.count++; +} + +function flushBatch(dirPath: string): void { + const batch = batches.get(dirPath); + if (!batch) return; + + const count = batch.count; + batches.delete(dirPath); + + if (!Notification.isSupported() || count === 0) return; + + const dirName = dirPath.split('/').pop() ?? dirPath; + new Notification({ + title: 'NFD → NFC 변환 완료', + body: `"${dirName}" 에서 ${count}개 파일명이 변환되었습니다.`, + silent: true, + }).show(); +} + +/** 수동 모드: 감지 즉시 알림 (사용자 액션 유도) */ +export async function notifyManualQueue(count: number, dirPath: string): Promise { + const settings = await store.getSettings(); + if (!settings.notificationsEnabled) return; + if (!Notification.isSupported()) return; + + const dirName = dirPath.split('/').pop() ?? dirPath; + new Notification({ + title: 'NFD 파일 감지됨', + body: `"${dirName}" 에서 ${count}개 대기 중. 트레이에서 처리하세요.`, + silent: true, + }).show(); +} + +/** 앱 종료 시 남아있는 배치를 즉시 발송 */ +export function flushAll(): void { + for (const [dirPath] of batches) { + flushBatch(dirPath); + } +} diff --git a/src/main/settings-window.ts b/src/main/settings-window.ts new file mode 100644 index 0000000..679304f --- /dev/null +++ b/src/main/settings-window.ts @@ -0,0 +1,59 @@ +import { BrowserWindow, shell, app } from 'electron'; +import { join } from 'path'; + +let settingsWin: BrowserWindow | null = null; + +export function openSettingsWindow(): void { + if (settingsWin && !settingsWin.isDestroyed()) { + settingsWin.focus(); + return; + } + + settingsWin = new BrowserWindow({ + width: 720, + height: 560, + title: 'NFD to NFC — 설정', + resizable: false, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: join(__dirname, '../preload/index.js'), + }, + }); + + settingsWin.on('closed', () => { + settingsWin = null; + app.dock?.hide(); + }); + + settingsWin.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + // 로딩 실패 시 콘솔 출력 + settingsWin.webContents.on('did-fail-load', (_e, code, desc, url) => { + console.error('[settings-window] did-fail-load:', code, desc, url); + }); + + const loadPromise = process.env['ELECTRON_RENDERER_URL'] + ? settingsWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings/index.html`) + : settingsWin.loadFile(join(__dirname, '../renderer/settings/index.html')); + + loadPromise + .then(() => { + app.dock?.show(); + settingsWin?.show(); + settingsWin?.focus(); + // 개발 모드에서 devtools 자동 오픈 + if (process.env['ELECTRON_RENDERER_URL']) { + settingsWin?.webContents.openDevTools({ mode: 'detach' }); + } + }) + .catch((err: Error) => console.error('[settings-window] load error:', err)); +} + +export function closeSettingsWindow(): void { + settingsWin?.close(); +} diff --git a/src/main/store.ts b/src/main/store.ts new file mode 100644 index 0000000..58d69bf --- /dev/null +++ b/src/main/store.ts @@ -0,0 +1,94 @@ +import { app } from 'electron'; +import fs from 'fs/promises'; +import path from 'path'; +import type { AppSchema, AppSettings, WatchedDir, UndoEntry } from '../core/types'; + +const STORE_PATH = path.join(app.getPath('userData'), 'store.json'); +const MAX_UNDO_LOG = 1000; + +const DEFAULT_SETTINGS: AppSettings = { + startAtLogin: false, + defaultMode: 'auto', + notificationsEnabled: true, +}; + +const DEFAULT_SCHEMA: AppSchema = { + watchedDirs: [], + settings: DEFAULT_SETTINGS, + undoLog: [], +}; + +let cache: AppSchema | null = null; + +async function load(): Promise { + if (cache) return cache; + try { + const raw = await fs.readFile(STORE_PATH, 'utf-8'); + cache = { ...DEFAULT_SCHEMA, ...JSON.parse(raw) }; + } catch { + cache = structuredClone(DEFAULT_SCHEMA); + } + return cache!; +} + +async function save(): Promise { + if (!cache) return; + await fs.mkdir(path.dirname(STORE_PATH), { recursive: true }); + await fs.writeFile(STORE_PATH, JSON.stringify(cache, null, 2), 'utf-8'); +} + +export async function getDirs(): Promise { + return (await load()).watchedDirs; +} + +export async function addDir(dir: WatchedDir): Promise { + const data = await load(); + data.watchedDirs.push(dir); + await save(); +} + +export async function updateDir(id: string, patch: Partial): Promise { + const data = await load(); + const idx = data.watchedDirs.findIndex((d) => d.id === id); + if (idx === -1) return; + data.watchedDirs[idx] = { ...data.watchedDirs[idx], ...patch }; + await save(); +} + +export async function removeDir(id: string): Promise { + const data = await load(); + data.watchedDirs = data.watchedDirs.filter((d) => d.id !== id); + await save(); +} + +export async function getSettings(): Promise { + return (await load()).settings; +} + +export async function updateSettings(patch: Partial): Promise { + const data = await load(); + data.settings = { ...data.settings, ...patch }; + await save(); +} + +export async function appendUndoEntries(entries: UndoEntry[]): Promise { + const data = await load(); + data.undoLog.push(...entries); + if (data.undoLog.length > MAX_UNDO_LOG) { + data.undoLog = data.undoLog.slice(-MAX_UNDO_LOG); + } + await save(); +} + +export async function getUndoLog(): Promise { + return (await load()).undoLog; +} + +export async function markUndoReverted(ids: string[]): Promise { + const data = await load(); + const set = new Set(ids); + data.undoLog.forEach((e) => { + if (set.has(e.id)) e.reverted = true; + }); + await save(); +} diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..fd222ba --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,111 @@ +import { Tray, Menu, BrowserWindow, nativeImage, app } from 'electron'; +import { join } from 'path'; +import { openSettingsWindow } from './settings-window'; +import * as watcher from './watcher'; +import { getDirs } from './store'; + +let tray: Tray | null = null; +let popoverWin: BrowserWindow | null = null; + +function buildTrayIcon(): Electron.NativeImage { + // resources/ 파일이 있으면 우선 사용 + const iconPath = join(__dirname, '../../resources/tray-icon-Template.png'); + const fromFile = nativeImage.createFromPath(iconPath); + if (!fromFile.isEmpty()) { + fromFile.setTemplateImage(true); + return fromFile; + } + // 없으면 32×32 비트맵으로 원형 아이콘 생성 (@2x → 16px 논리 크기) + const SIZE = 32; + const data = Buffer.alloc(SIZE * SIZE * 4, 0); + const cx = SIZE / 2, cy = SIZE / 2, r = SIZE * 0.38; + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + if (Math.hypot(x - cx, y - cy) <= r) { + const i = (y * SIZE + x) * 4; + data[i + 3] = 255; // alpha only — template image가 색을 결정 + } + } + } + const img = nativeImage.createFromBitmap(data, { width: SIZE, height: SIZE, scaleFactor: 2.0 }); + img.setTemplateImage(true); + return img; +} + +export function createTray(): void { + const icon = buildTrayIcon(); + + tray = new Tray(icon); + tray.setToolTip('NFD to NFC 변환기'); + + tray.on('click', () => togglePopover()); + tray.on('right-click', () => showContextMenu()); +} + +function togglePopover(): void { + if (popoverWin && !popoverWin.isDestroyed()) { + popoverWin.close(); + return; + } + + const bounds = tray!.getBounds(); + popoverWin = new BrowserWindow({ + width: 300, + height: 400, + x: Math.round(bounds.x - 150 + bounds.width / 2), + y: Math.round(bounds.y + bounds.height + 4), + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: join(__dirname, '../preload/index.js'), + }, + }); + + popoverWin.on('blur', () => { + popoverWin?.close(); + }); + + popoverWin.on('closed', () => { + popoverWin = null; + }); + + if (process.env['ELECTRON_RENDERER_URL']) { + popoverWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popover/index.html`); + } else { + popoverWin.loadFile(join(__dirname, '../renderer/popover/index.html')); + } + + popoverWin.once('ready-to-show', () => popoverWin?.show()); +} + +function showContextMenu(): void { + // watcher 모듈이 단일 진실의 원천 — 여기서 직접 읽음 + const paused = watcher.isGloballyPaused(); + const menu = Menu.buildFromTemplate([ + { + label: paused ? '감시 재개' : '감시 일시 정지', + click: async () => { + if (watcher.isGloballyPaused()) { + const dirs = await getDirs(); + await watcher.resumeAll(dirs); + } else { + await watcher.pauseAll(); + } + }, + }, + { label: '설정…', click: () => openSettingsWindow() }, + { type: 'separator' }, + { label: '종료', click: () => app.quit() }, + ]); + tray!.popUpContextMenu(menu); +} + +export function destroyTray(): void { + tray?.destroy(); + tray = null; +} diff --git a/src/main/watcher.ts b/src/main/watcher.ts new file mode 100644 index 0000000..aa28701 --- /dev/null +++ b/src/main/watcher.ts @@ -0,0 +1,165 @@ +import fs from 'fs/promises'; +import chokidar, { FSWatcher } from 'chokidar'; +import { BrowserWindow } from 'electron'; +import { nanoid } from './nanoid'; +import { shouldNormalize } from '../core/filter'; +import { normalizeEntry } from '../core/normalizer'; +import type { WatchedDir, ActivityEvent, RenameResult } from '../core/types'; +import * as store from './store'; +import * as notifier from './notifier'; + +type ActivityListener = (event: ActivityEvent) => void; + +const watchers = new Map(); +const recentlyRenamed = new Map(); +const RENAME_TTL = 2000; +const listeners = new Set(); +const manualQueue = new Map(); + +// 전역 일시정지 상태 — 단일 진실의 원천 +let globallyPaused = false; + +export function isGloballyPaused(): boolean { + return globallyPaused; +} + +export function onActivity(listener: ActivityListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function emit(event: ActivityEvent): void { + for (const l of listeners) l(event); + BrowserWindow.getAllWindows().forEach((w) => { + if (!w.isDestroyed()) w.webContents.send('watcher:activity', event); + }); +} + +// 일시정지 상태 변경을 렌더러에 브로드캐스트 +function emitPauseState(): void { + BrowserWindow.getAllWindows().forEach((w) => { + if (!w.isDestroyed()) w.webContents.send('watcher:paused', globallyPaused); + }); +} + +export async function startDir(dir: WatchedDir): Promise { + if (watchers.has(dir.id)) return; + + const filterOpts = { customRanges: dir.customRanges }; + const w = chokidar.watch(dir.path, { + ignoreInitial: false, + persistent: true, + depth: dir.recursive ? undefined : 0, + awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }, + ignored: /(^|[/\\])\../, + }); + + async function handlePath(filePath: string, type: 'file' | 'directory'): Promise { + const now = Date.now(); + const lastRenamed = recentlyRenamed.get(filePath); + if (lastRenamed && now - lastRenamed < RENAME_TTL) return; + + const basename = filePath.split('/').pop() ?? ''; + if (!shouldNormalize(basename, filterOpts)) return; + + if (dir.mode === 'auto') { + const result = await normalizeEntry(filePath, type, filterOpts); + if (result.status === 'renamed' || result.status === 'noop-same-inode') { + recentlyRenamed.set(result.newPath, Date.now()); + if (result.status === 'renamed') { + await store.appendUndoEntries([ + { id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }, + ]); + } + emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result }); + // 알림은 배치로 처리 — 인터벌마다 총 건수 발송 + await notifier.queueRenamedNotification(dir.path); + } else if (result.status === 'collision') { + emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result }); + } + } else { + const queue = manualQueue.get(dir.id) ?? []; + if (!queue.includes(filePath)) { + queue.push(filePath); + manualQueue.set(dir.id, queue); + emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` }); + } + await notifier.notifyManualQueue(queue.length, dir.path); + } + } + + w.on('add', (p) => handlePath(p, 'file').catch(console.error)) + .on('addDir', (p) => { + if (p === dir.path) return; + handlePath(p, 'directory').catch(console.error); + }) + .on('error', (err) => { + emit({ type: 'error', ts: Date.now(), dirId: dir.id, message: String(err) }); + }); + + watchers.set(dir.id, w); +} + +export async function stopDir(dirId: string): Promise { + const w = watchers.get(dirId); + if (!w) return; + await w.close(); + watchers.delete(dirId); + manualQueue.delete(dirId); +} + +/** 전역 일시정지: 모든 감시 중단 + 상태 플래그 변경 */ +export async function pauseAll(): Promise { + globallyPaused = true; + for (const [id] of [...watchers]) await stopDir(id); + emitPauseState(); +} + +/** 전역 재개: 지정된 dirs로 감시 재시작 + 상태 플래그 변경 */ +export async function resumeAll(dirs: WatchedDir[]): Promise { + globallyPaused = false; + for (const d of dirs.filter((x) => x.enabled)) { + await startDir(d); + } + emitPauseState(); +} + +/** 앱 종료용 정리 — 일시정지 플래그 변경 없음 */ +export async function stopAll(): Promise { + for (const [id] of [...watchers]) await stopDir(id); +} + +export function getPendingQueue(dirId: string): string[] { + return manualQueue.get(dirId) ?? []; +} + +export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise { + const queue = manualQueue.get(dirId) ?? []; + const filterOpts = { customRanges: dir.customRanges }; + const results: RenameResult[] = []; + const undoEntries = []; + const now = Date.now(); + + for (const filePath of queue) { + try { + const stat = await fs.stat(filePath).catch(() => null); + if (!stat) continue; + const type = stat.isDirectory() ? 'directory' : 'file'; + const result = await normalizeEntry(filePath, type, filterOpts); + results.push(result); + if (result.status === 'renamed') { + recentlyRenamed.set(result.newPath, Date.now()); + undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }); + } + } catch (err) { + console.error('applyManualQueue error:', err); + } + } + + manualQueue.set(dirId, []); + if (undoEntries.length) await store.appendUndoEntries(undoEntries); + // 수동 적용은 즉시 배치에 추가 (인터벌 내 합산) + const renamedCount = results.filter((r) => r.status === 'renamed').length; + for (let i = 0; i < renamedCount; i++) await notifier.queueRenamedNotification(dir.path); + return results; +}