From 2175c3103e9d938614f39dcce70e5172fe8bd0c1 Mon Sep 17 00:00:00 2001 From: chen xi Date: Sat, 16 May 2026 16:28:27 +0800 Subject: [PATCH] Group duplicate process names in interactive mode --- interactive.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++--- test.js | 15 +++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/interactive.js b/interactive.js index 4983d8d..236636c 100644 --- a/interactive.js +++ b/interactive.js @@ -13,6 +13,7 @@ import FuzzySearch from 'fuzzy-search'; const isWindows = process.platform === 'win32'; const commandLineMargins = 4; +const processGroupSymbol = Symbol('processGroup'); const PROCESS_EXITED_MIN_INTERVAL = 5; const PROCESS_EXITED_MAX_INTERVAL = 1280; @@ -117,6 +118,42 @@ const renderProcessForDisplay = (process_, flags, memoryThreshold, cpuThreshold) }; }; +const renderProcessGroupForDisplay = (processGroup, flags, memoryThreshold, cpuThreshold) => { + const mostExpensiveProcess = [...processGroup.processes].sort(preferHighPerformanceImpact)[0]; + const displayProcess = { + ...mostExpensiveProcess, + name: `${processGroup.name} (${processGroup.processes.length} processes)`, + cmd: `${processGroup.name} (${processGroup.processes.length} processes)`, + pid: processGroup.processes[0].pid, + ports: processGroup.processes.flatMap(process_ => process_.ports), + }; + const renderedProcess = renderProcessForDisplay(displayProcess, flags, memoryThreshold, cpuThreshold); + + return { + ...renderedProcess, + value: processGroup, + }; +}; + +const groupProcessesByName = processes => { + const processGroups = new Map(); + + for (const process_ of processes) { + const processGroup = processGroups.get(process_.name) ?? { + [processGroupSymbol]: true, + name: process_.name, + processes: [], + }; + + processGroup.processes.push(process_); + processGroups.set(process_.name, processGroup); + } + + return [...processGroups.values()].flatMap(processGroup => processGroup.processes.length === 1 ? processGroup.processes : processGroup); +}; + +const isProcessGroup = value => value?.[processGroupSymbol] === true; + const searchProcessesByPort = (processes, port) => processes.filter(process_ => process_.ports.includes(port)); const searchProcessByPid = (processes, pid) => processes.find(process_ => String(process_.pid) === pid); @@ -175,6 +212,10 @@ const filterAndSortProcesses = (processes, term, searcher, flags) => { return searchProcessesByName(filtered, term, searcher, flags); }; +const renderProcessOrGroupForDisplay = (processOrGroup, flags, memoryThreshold, cpuThreshold) => isProcessGroup(processOrGroup) + ? renderProcessGroupForDisplay(processOrGroup, flags, memoryThreshold, cpuThreshold) + : renderProcessForDisplay(processOrGroup, flags, memoryThreshold, cpuThreshold); + const handleFkillError = async (inputs, flags = {}) => { const shouldForceKill = await promptForceKill(inputs, 'Error killing process.'); @@ -239,6 +280,31 @@ const performKillSequence = async processes => { } }; +const selectProcessesFromGroup = async processGroup => { + const answer = await inquirer.prompt([{ + type: 'list', + name: 'selection', + message: `Kill ${processGroup.name}:`, + choices: [ + { + name: `All ${processGroup.processes.length} processes`, + value: processGroup.processes.map(process_ => process_.pid), + }, + ...processGroup.processes.map(process_ => ({ + name: `${process_.name} ${chalk.dim(process_.pid)}`, + value: process_.pid, + })), + ], + }]); + + return answer.selection; +}; + +const performSelectedKillSequence = async selectedValue => { + const processSelection = isProcessGroup(selectedValue) ? await selectProcessesFromGroup(selectedValue) : selectedValue; + await performKillSequence(processSelection); +}; + const findPortsForProcess = (processId, portToPidMap) => { const ports = []; @@ -261,11 +327,12 @@ const listProcesses = async (processes, flags) => { pageSize: 10, async source(term = '') { const matchingProcesses = filterAndSortProcesses(processes, term, searcher, flags); - return matchingProcesses.map(process_ => renderProcessForDisplay(process_, flags, memoryThreshold, cpuThreshold)); + return groupProcessesByName(matchingProcesses) + .map(processOrGroup => renderProcessOrGroupForDisplay(processOrGroup, flags, memoryThreshold, cpuThreshold)); }, }); - performKillSequence(selectedPid); + await performSelectedKillSequence(selectedPid); }; const init = async flags => { @@ -284,4 +351,4 @@ const init = async flags => { listProcesses(processesWithPorts, flags); }; -export {init, handleFkillError}; +export {init, handleFkillError, groupProcessesByName}; diff --git a/test.js b/test.js index 2273327..c34be66 100644 --- a/test.js +++ b/test.js @@ -6,6 +6,7 @@ import delay from 'delay'; import noopProcess from 'noop-process'; import {processExists} from 'process-exists'; import getPort from 'get-port'; +import {groupProcessesByName} from './interactive.js'; const noopProcessKilled = async (t, pid) => { // Ensure the noop process has time to exit @@ -57,6 +58,20 @@ test('silently force killing process at unused port exits with code 0', async t t.is(exitCode, 0); }); +test('groups processes with the same name', t => { + const processes = [ + {name: 'node', pid: 1}, + {name: 'node', pid: 2}, + {name: 'zsh', pid: 3}, + ]; + const result = groupProcessesByName(processes); + + t.is(result.length, 2); + t.is(result[0].name, 'node'); + t.deepEqual(result[0].processes, [processes[0], processes[1]]); + t.is(result[1], processes[2]); +}); + // Case-sensitivity tests only work on Unix-like systems // Windows process names work differently and don't support custom titles via noopProcess if (process.platform !== 'win32') {