Merge pull request #13 from 777genius/improvements

feat: enhance FAQ section in README and improve cross-platform handling
This commit is contained in:
Илия 2026-03-02 23:11:04 +02:00 committed by GitHub
commit 9319189ff9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 552 additions and 174 deletions

View file

@ -60,6 +60,58 @@ A new approach to task management with AI agents.
---
-->
## FAQ
<details>
<summary><strong>Do I need to install Claude Code before using this app?</strong></summary>
<br />
No. The app includes built-in installation and authentication — just launch and follow the setup wizard.
</details>
<details>
<summary><strong>Does it read or upload my code?</strong></summary>
<br />
No. Everything runs locally on your machine. The app reads Claude Code's session logs from <code>~/.claude/</code> — your source code is never sent anywhere.
</details>
<details>
<summary><strong>Can agents communicate with each other?</strong></summary>
<br />
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol.
</details>
<details>
<summary><strong>Is it free?</strong></summary>
<br />
Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
</details>
<details>
<summary><strong>Can I review code changes before they're applied?</strong></summary>
<br />
Yes. Every task shows a full diff view where you can accept, reject, or comment on individual code hunks — similar to Cursor's review flow.
</details>
<details>
<summary><strong>What happens if an agent gets stuck?</strong></summary>
<br />
You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it.
</details>
<details>
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
<br />
Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history.
</details>
<details>
<summary><strong>Does it support multiple projects and teams?</strong></summary>
<br />
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, ask agents to use git worktree in your provisioning prompt.
</details>
---
## Installation
No prerequisites — Claude Code can be installed and configured directly from the app.

View file

@ -844,6 +844,9 @@ function createWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (input.type !== 'keyDown') return;
// Cmd on macOS, Ctrl on Windows/Linux — unified modifier for cross-platform shortcuts
const isMod = input.meta || input.control;
// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
@ -852,14 +855,14 @@ function createWindow(): void {
return;
}
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts
if (input.meta && input.key.toLowerCase() === 'n') {
// Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts
if (isMod && input.key.toLowerCase() === 'n') {
event.preventDefault();
mainWindow.webContents.send('review:cmdN');
return;
}
if (!input.meta) return;
if (!isMod) return;
const currentLevel = mainWindow.webContents.getZoomLevel();

View file

@ -948,6 +948,16 @@ async function resolveWslHome(distro: string): Promise<string | null> {
}
}
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
try {
const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000);
const user = stdout.trim();
return user && !user.includes('/') && !user.includes('\\') ? user : null;
} catch {
return null;
}
}
/**
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
*/
@ -968,7 +978,14 @@ async function handleFindWslClaudeRoots(
const seen = new Set<string>();
for (const distro of distros) {
const resolvedHomePath = await resolveWslHome(distro);
const fallbackHomePath = process.env.USERNAME ? `/home/${process.env.USERNAME}` : null;
// Fallback: query the default WSL user, then try Windows USERNAME
const wslUser = await resolveWslDefaultUser(distro);
const fallbackUser = wslUser || process.env.USERNAME;
const fallbackHomePath = fallbackUser
? fallbackUser === 'root'
? '/root'
: `/home/${fallbackUser}`
: null;
const normalizedHome =
normalizeWslHomePath(resolvedHomePath ?? '') ??
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);

View file

@ -1764,11 +1764,13 @@ export function showTeamNativeNotification(opts: {
return;
}
const isMac = process.platform === 'darwin';
const truncatedBody = opts.body.slice(0, 300);
const iconPath = getAppIconPath();
const notification = new Notification({
title: opts.title,
subtitle: opts.subtitle,
body: opts.body.slice(0, 300),
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody,
sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});

View file

@ -3,7 +3,7 @@
*
* Responsibilities:
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
* - Show native macOS notifications using Electron's Notification API
* - Show native notifications using Electron's Notification API (cross-platform)
* - Implement throttling (5 seconds per unique error hash)
* - Respect config.notifications.enabled and snoozedUntil
* - Filter errors matching ignoredRegex patterns
@ -380,7 +380,9 @@ export class NotificationManager extends EventEmitter {
// ===========================================================================
/**
* Shows a native macOS notification for an error.
* Shows a native notification for an error.
* Note: Electron's `subtitle` option only works on macOS.
* On Windows/Linux, we prepend the subtitle to the body instead.
*/
private showNativeNotification(error: DetectedError): void {
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
@ -395,11 +397,13 @@ export class NotificationManager extends EventEmitter {
const config = this.configManager.getConfig();
const isMac = process.platform === 'darwin';
const truncatedMessage = error.message.slice(0, 200);
const iconPath = getAppIconPath();
const notification = new Notification({
title: 'Claude Code Error',
subtitle: error.context.projectName,
body: error.message.slice(0, 200),
...(isMac ? { subtitle: error.context.projectName } : {}),
body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});

View file

@ -396,7 +396,7 @@ export class SshConnectionManager extends EventEmitter {
* Resolves authentication automatically by trying:
* 1. IdentityFile from SSH config
* 2. SSH agent
* 3. Default key files (id_ed25519, id_rsa)
* 3. Default key files (id_ed25519, id_rsa, id_ecdsa)
*/
private async resolveAutoAuth(
sshConfig: SshConfigHostEntry | null
@ -407,10 +407,7 @@ export class SshConnectionManager extends EventEmitter {
if (resolved) {
// The config parser already told us there's an identity file.
// Try common identity file locations from config
const configKeyPaths = [
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
];
const configKeyPaths = this.getSshKeyPaths();
for (const keyPath of configKeyPaths) {
try {
const keyData = await fs.promises.readFile(keyPath, 'utf8');
@ -429,11 +426,7 @@ export class SshConnectionManager extends EventEmitter {
}
// Try default key files
const defaultKeys = [
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
path.join(getHomeDir(), '.ssh', 'id_ecdsa'),
];
const defaultKeys = this.getSshKeyPaths();
for (const keyPath of defaultKeys) {
try {
@ -447,6 +440,28 @@ export class SshConnectionManager extends EventEmitter {
return {};
}
/**
* Returns SSH key candidate paths for the current platform.
* On Windows, also checks %USERPROFILE%\.ssh\ (OpenSSH for Windows default).
*/
private getSshKeyPaths(): string[] {
const home = getHomeDir();
const keyNames = ['id_ed25519', 'id_rsa', 'id_ecdsa'];
const candidates = keyNames.map((name) => path.join(home, '.ssh', name));
// On Windows, USERPROFILE may differ from getHomeDir() when HOME is overridden
if (process.platform === 'win32') {
const userProfile = process.env.USERPROFILE;
if (userProfile && userProfile !== home) {
for (const name of keyNames) {
candidates.push(path.join(userProfile, '.ssh', name));
}
}
}
return candidates;
}
private async resolveRemoteProjectsPath(username: string): Promise<string> {
// Prefer remote $HOME when available, then fall back to common paths.
const remoteHome = await this.resolveRemoteHomeDirectory();

View file

@ -61,6 +61,10 @@ function expandWindowsBinaryNames(binaryName: string): string[] {
}
async function collectNvmCandidates(): Promise<string[]> {
if (process.platform === 'win32') {
return collectNvmWindowsCandidates();
}
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
let versions: string[];
try {
@ -75,6 +79,28 @@ async function collectNvmCandidates(): Promise<string[]> {
.reverse();
}
/**
* Collect NVM for Windows (nvm-windows) candidates.
* nvm-windows stores Node versions under %APPDATA%\nvm\<version>\.
*/
async function collectNvmWindowsCandidates(): Promise<string[]> {
const appdata = process.env.APPDATA;
if (!appdata) return [];
const nvmRoot = path.join(appdata, 'nvm');
let versions: string[];
try {
versions = await fs.promises.readdir(nvmRoot);
} catch {
return [];
}
const exts = getWindowsExecutableExtensions();
return versions
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }))
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
}
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const rawPath = process.env.PATH;
if (!rawPath) {
@ -176,22 +202,34 @@ export class ClaudeBinaryResolver {
const platformBinaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const candidateDirs: string[] = [
// Native binary installation path (claude install)
path.join(getHomeDir(), '.local', 'bin'),
path.join(getHomeDir(), '.npm-global', 'bin'),
path.join(getHomeDir(), '.npm', 'bin'),
const candidateDirs: string[] =
process.platform === 'win32'
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
: '/usr/local/bin',
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
].filter((candidate) => candidate.length > 0);
? [
// Windows: npm global install
path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
// Windows: scoop, chocolatey, and other package managers
path.join(getHomeDir(), 'scoop', 'shims'),
// Windows: Local programs
...(process.env.LOCALAPPDATA
? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
: []),
// Windows: Program Files
...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
]
: [
// Unix: native binary installation path (claude install)
path.join(getHomeDir(), '.local', 'bin'),
path.join(getHomeDir(), '.npm-global', 'bin'),
path.join(getHomeDir(), '.npm', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
];
const candidates = candidateDirs.flatMap((dir) =>
platformBinaryNames.map((name) => path.join(dir, name))
);
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
const nvmCandidates = await collectNvmCandidates();
const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed

View file

@ -85,7 +85,12 @@ function parseArgs(argv) {
}
function getHomeDir() {
return process.env.HOME || process.env.USERPROFILE || '';
if (process.env.HOME) return process.env.HOME;
if (process.env.USERPROFILE) return process.env.USERPROFILE;
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
return process.env.HOMEDRIVE + process.env.HOMEPATH;
}
try { return require('os').homedir(); } catch { return ''; }
}
function getClaudeDir(flags) {
@ -98,7 +103,7 @@ function getClaudeDir(flags) {
const inferred = inferClaudeDirFromScriptPath();
if (inferred) return inferred;
const home = getHomeDir();
if (!home) die('HOME is not set');
if (!home) die('HOME/USERPROFILE is not set');
return path.join(home, '.claude');
}
@ -148,7 +153,26 @@ function atomicWrite(filePath, data) {
'.' +
String(Math.random().toString(16).slice(2));
fs.writeFileSync(tmp, data, 'utf8');
fs.renameSync(tmp, filePath);
// On Windows, fs.renameSync can throw EPERM/EACCES when another process
// is concurrently renaming to the same target. Retry with backoff.
const maxRetries = process.platform === 'win32' ? 5 : 1;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
fs.renameSync(tmp, filePath);
return;
} catch (e) {
if (attempt < maxRetries - 1 && e && (e.code === 'EPERM' || e.code === 'EACCES')) {
// Busy wait — small random delay to reduce contention
const ms = Math.floor(Math.random() * 50) + 10;
const end = Date.now() + ms;
while (Date.now() < end) { /* spin */ }
continue;
}
// Clean up temp file on final failure
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw e;
}
}
}
function normalizeStatus(value) {
@ -976,8 +1000,8 @@ async function main() {
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {
@ -1053,8 +1077,8 @@ async function main() {
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {

View file

@ -6,6 +6,7 @@ import {
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
@ -458,9 +459,9 @@ export class TeamDataService {
async killProcess(teamName: string, pid: number): Promise<void> {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
// Try to kill the process
// Try to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows)
try {
process.kill(pid, 'SIGTERM');
killProcessByPid(pid);
} catch (err: unknown) {
// ESRCH = process not found — still mark as stopped below
if (

View file

@ -2700,15 +2700,26 @@ export class TeamProvisioningService {
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled.
const electronHome = getHomeDir();
const isWindows = process.platform === 'win32';
const home = shellEnv.HOME?.trim() || electronHome;
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
const xdgConfigHome =
shellEnv.XDG_CONFIG_HOME?.trim() || process.env.XDG_CONFIG_HOME?.trim() || `${home}/.config`;
const xdgStateHome =
shellEnv.XDG_STATE_HOME?.trim() ||
process.env.XDG_STATE_HOME?.trim() ||
`${home}/.local/state`;
let osUsername = '';
try {
osUsername = os.userInfo().username;
} catch {
// os.userInfo() can throw SystemError in restricted environments (no passwd entry, Docker, etc.)
}
const user =
shellEnv.USER?.trim() ||
process.env.USER?.trim() ||
process.env.USERNAME?.trim() ||
osUsername ||
'unknown';
// Shell: on Windows there is no SHELL env var; use COMSPEC (cmd.exe / powershell).
// On Unix, prefer the user's login shell from env or fall back to /bin/zsh.
const shell = isWindows
? (process.env.COMSPEC ?? 'powershell.exe')
: shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
const env: NodeJS.ProcessEnv = {
...process.env,
@ -2717,16 +2728,33 @@ export class TeamProvisioningService {
USERPROFILE: home,
USER: user,
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
SHELL: shell,
TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color',
XDG_CONFIG_HOME: xdgConfigHome,
XDG_STATE_HOME: xdgStateHome,
// Ensure CLI reads/writes from the same Claude root as the app.
// This aligns teams/tasks locations when the app overrides claudeRootPath.
CLAUDE_CONFIG_DIR: getClaudeBasePath(),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
// SHELL is a Unix concept — only set it on non-Windows platforms.
if (!isWindows) {
env.SHELL = shell;
}
// XDG directories are a freedesktop.org (Linux/macOS) convention.
// On Windows, these are unused by most tools and can cause confusion.
if (!isWindows) {
const xdgConfigHome =
shellEnv.XDG_CONFIG_HOME?.trim() ||
process.env.XDG_CONFIG_HOME?.trim() ||
`${home}/.config`;
const xdgStateHome =
shellEnv.XDG_STATE_HOME?.trim() ||
process.env.XDG_STATE_HOME?.trim() ||
`${home}/.local/state`;
env.XDG_CONFIG_HOME = xdgConfigHome;
env.XDG_STATE_HOME = xdgStateHome;
}
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { env, authSource: 'anthropic_api_key' };

View file

@ -176,8 +176,11 @@ async function shutdown(): Promise<void> {
// Signal Handlers
// =============================================================================
process.on('SIGTERM', () => void shutdown());
// SIGINT works on all platforms (Ctrl+C), but SIGTERM does not exist on Windows.
process.on('SIGINT', () => void shutdown());
if (process.platform !== 'win32') {
process.on('SIGTERM', () => void shutdown());
}
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection:', reason);

View file

@ -1,3 +1,17 @@
/**
* Check whether a process with the given PID is still alive.
*
* Cross-platform notes:
* - `process.kill(pid, 0)` sends signal 0 (a no-op probe) on all platforms.
* On Windows, Node.js internally calls `OpenProcess()` which works correctly
* for same-user processes.
* - EPERM means the process exists but we lack permission to signal it
* still counts as alive.
* - ESRCH (Unix) or ERROR_INVALID_PARAMETER (Windows, mapped to ESRCH by
* Node.js) means the process does not exist.
* - On Windows, zombie/defunct processes are not a concern because Windows
* cleans up process handles immediately upon exit (no Unix-style zombies).
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);

View file

@ -0,0 +1,34 @@
import { execFile } from 'child_process';
import path from 'path';
/**
* Kill a process by PID in a cross-platform manner.
*
* On Unix: sends SIGTERM, which allows the process to handle the signal gracefully.
* On Windows: uses `taskkill /T /F /PID` to kill the entire process tree.
* - `process.kill(pid, 'SIGTERM')` on Windows does NOT actually send a signal
* it calls TerminateProcess() which is equivalent to SIGKILL (immediate, ungraceful).
* - `taskkill /T` also kills child processes, preventing orphaned process trees.
*
* On Unix, throws if the process cannot be killed (except ESRCH process already dead).
* On Windows, taskkill is best-effort (async fire-and-forget) to match killProcessTree() semantics.
*/
export function killProcessByPid(pid: number): void {
if (process.platform === 'win32') {
try {
const taskkillPath = path.join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'taskkill.exe'
);
execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], () => {
// Best-effort — ignore errors (process may have already exited)
});
} catch {
// taskkill failed to spawn, fall through to process.kill()
process.kill(pid, 'SIGTERM');
}
} else {
process.kill(pid, 'SIGTERM');
}
}

View file

@ -14,6 +14,14 @@ import { Minus, Square, X } from 'lucide-react';
const TITLE_BAR_HEIGHT = 32;
/**
* Detect whether the custom title bar should be shown.
*
* In Electron, the userAgent string reliably contains the OS name
* (e.g. "Windows NT 10.0", "Linux x86_64"), so this check works on
* all three platforms. macOS is excluded because it uses native
* traffic-light window controls instead.
*/
function needsCustomTitleBar(): boolean {
if (!isElectronMode()) return false;
const ua = window.navigator.userAgent;

View file

@ -13,12 +13,13 @@ import {
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
@ -350,27 +351,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
};
}, [projectId]);
// Resolve lead's git branch from project path
const [leadBranch, setLeadBranch] = useState<string | null>(null);
useEffect(() => {
const projectPath = data?.config.projectPath?.trim();
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
setLeadBranch(null);
return;
}
let cancelled = false;
void api.teams.getProjectBranch(projectPath).then(
(branch) => {
if (!cancelled) setLeadBranch(branch);
},
() => {
if (!cancelled) setLeadBranch(null);
}
);
return () => {
cancelled = true;
};
}, [data?.config.projectPath]);
// Live git branch polling for the team's project path
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
const branchSyncPaths = useMemo(
() => (teamProjectPath ? [teamProjectPath] : []),
[teamProjectPath]
);
useBranchSync(branchSyncPaths, { live: true });
const leadBranch = useStore((s) =>
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessions = useMemo(() => {

View file

@ -12,6 +12,7 @@ import {
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
@ -182,7 +183,6 @@ export const TeamListView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const {
teams,
teamsLoading,
@ -337,48 +337,13 @@ export const TeamListView = (): React.JSX.Element => {
leadActivityByTeam,
]);
// Live branch/worktree for team project paths (poll so it updates during process)
const projectPathsToPoll = useMemo(() => {
const byKey = new Map<string, string>();
for (const team of filteredTeams) {
const p = team.projectPath?.trim();
if (p) {
const key = normalizePath(p);
if (!byKey.has(key)) byKey.set(key, p);
}
}
return Array.from(byKey.entries());
}, [filteredTeams]);
useEffect(() => {
if (!electronMode || projectPathsToPoll.length === 0) return;
let cancelled = false;
const poll = async (): Promise<void> => {
const next = new Map<string, string | null>();
for (const [pathKey, actualPath] of projectPathsToPoll) {
if (cancelled) return;
try {
const branch = await api.teams.getProjectBranch(actualPath);
if (!cancelled) next.set(pathKey, branch);
} catch {
if (!cancelled) next.set(pathKey, null);
}
}
if (!cancelled && next.size > 0) {
setBranchByPath((prev) => {
const m = new Map(prev);
for (const [k, v] of next) m.set(k, v);
return m;
});
}
};
void poll();
const interval = setInterval(poll, 6000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [electronMode, projectPathsToPoll]);
// Fetch branches once for all visible team project paths (no live polling)
const teamPaths = useMemo(
() => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[],
[filteredTeams]
);
useBranchSync(teamPaths, { live: false });
const branchByPath = useStore((s) => s.branchByPath);
const restoreTeam = useStore((s) => s.restoreTeam);
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
@ -748,7 +713,7 @@ export const TeamListView = (): React.JSX.Element => {
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
const branch = branchByPath[normalizePath(team.projectPath)];
if (!branch) return null;
return (
<span

View file

@ -4,6 +4,7 @@
*/
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
import { getBasename } from '@shared/utils/platformPath';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
import { EditorImagePreview } from './EditorImagePreview';
@ -17,7 +18,7 @@ export const EditorBinaryState = ({
filePath,
size,
}: EditorBinaryStateProps): React.ReactElement => {
const fileName = filePath.split('/').pop() ?? filePath;
const fileName = getBasename(filePath) || filePath;
const previewType = getPreviewType(fileName);
if (previewType === 'image' && isPreviewable(fileName, size)) {

View file

@ -7,6 +7,7 @@
import { useCallback, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { splitPath } from '@shared/utils/platformPath';
import { ChevronRight } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -32,7 +33,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
? activeTabId.slice(projectPath.length + 1)
: activeTabId;
return relativePath.split('/');
return splitPath(relativePath);
}, [activeTabId, projectPath]);
const handleSegmentClick = useCallback(

View file

@ -9,6 +9,7 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { lastSeparatorIndex } from '@shared/utils/platformPath';
import {
ClipboardCopy,
FilePlus,
@ -84,7 +85,7 @@ export const EditorContextMenu = ({
const parentDir = target
? target.isDir
? target.path
: target.path.substring(0, target.path.lastIndexOf('/'))
: target.path.substring(0, lastSeparatorIndex(target.path))
: null;
return (

View file

@ -27,6 +27,7 @@ import {
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -350,7 +351,7 @@ export const EditorFileTree = ({
const item = flatItemsByPath.get(overId);
if (item) {
const p = item.node.fullPath;
targetDir = p.substring(0, p.lastIndexOf('/'));
targetDir = p.substring(0, lastSeparatorIndex(p));
}
}
@ -389,7 +390,7 @@ export const EditorFileTree = ({
}
const destDir = dropTargetPath;
const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
// Validation: same folder = no-op
if (sourceParent === destDir) {
@ -543,7 +544,7 @@ export const EditorFileTree = ({
<DialogHeader>
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
<DialogDescription>
Move &ldquo;{deleteConfirmPath?.split('/').pop() ?? ''}&rdquo; to Trash?
Move &ldquo;{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}&rdquo; to Trash?
</DialogDescription>
</DialogHeader>
<DialogFooter>

View file

@ -21,6 +21,7 @@ import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardSho
import { useStore } from '@renderer/store';
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { getBasename, getDirname } from '@shared/utils/platformPath';
import {
AlertTriangle,
HelpCircle,
@ -216,7 +217,7 @@ export const ProjectEditorOverlay = ({
const result = await promise;
const ipcMs = performance.now() - t0;
console.debug(
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}`
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${getBasename(filePath)}`
);
setFileContent(result);
@ -519,7 +520,7 @@ export const ProjectEditorOverlay = ({
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
});
const projectName = projectPath.split('/').pop() ?? projectPath;
const projectName = getBasename(projectPath) || projectPath;
return (
<div
@ -785,7 +786,7 @@ export const ProjectEditorOverlay = ({
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
fileName={getBasename(activeTabId) || 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
@ -803,7 +804,7 @@ export const ProjectEditorOverlay = ({
splitRatio={splitRatio}
onSplitRatioChange={handleSplitRatioChange}
viewKey={activeTabId}
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
baseDir={activeTabId ? getDirname(activeTabId) : undefined}
/>
)}
</div>

View file

@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon';
@ -263,10 +264,9 @@ const SearchFileGroup = ({
query,
caseSensitive,
}: SearchFileGroupProps): React.ReactElement => {
const fileName = relativePath.split('/').pop() ?? relativePath;
const dirPath = relativePath.includes('/')
? relativePath.slice(0, relativePath.lastIndexOf('/'))
: '';
const fileName = getBasename(relativePath) || relativePath;
const sepIdx = lastSeparatorIndex(relativePath);
const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : '';
return (
<div className="border-border/50 border-b">
<button

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { cn } from '@renderer/lib/utils';
import { formatRelativeTime } from '@renderer/utils/formatters';
import { getBasename } from '@shared/utils/platformPath';
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
import {
AlertCircle,
@ -247,7 +248,7 @@ const FilesTouchedSection = ({
</div>
<div className="space-y-0.5">
{visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath;
const basename = getBasename(filePath) || filePath;
const fStats = fileStats?.[filePath];
return (
<button

View file

@ -0,0 +1,128 @@
/**
* Centralized git branch polling hook.
*
* Provides two modes:
* - `live: false` (default) one-shot fetch on mount / path change
* - `live: true` continuous polling with ref-counted shared timer
*
* Data is stored in the Zustand store (`branchByPath`) so any component
* can read it via `useStore(s => s.branchByPath)`.
*
* The module-level polling manager guarantees:
* - A single shared `setInterval` across all live subscribers
* - Deduplication: N components subscribing to the same path = 1 poll
* - Automatic cleanup: timer stops when all subscribers unmount
*/
import { useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
// =============================================================================
// Constants
// =============================================================================
const POLL_INTERVAL_MS = 6_000;
// =============================================================================
// Module-level polling manager (singleton, outside React lifecycle)
// =============================================================================
const livePaths = new Map<string, { actualPath: string; refCount: number }>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPollingIfNeeded(): void {
if (pollTimer || livePaths.size === 0) return;
pollTimer = setInterval(() => {
const paths = Array.from(livePaths.values()).map((v) => v.actualPath);
void useStore.getState().fetchBranches(paths);
}, POLL_INTERVAL_MS);
}
function stopPollingIfEmpty(): void {
if (pollTimer && livePaths.size === 0) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function subscribe(normalizedKey: string, actualPath: string): void {
const entry = livePaths.get(normalizedKey);
if (entry) {
entry.refCount++;
} else {
livePaths.set(normalizedKey, { actualPath, refCount: 1 });
}
startPollingIfNeeded();
}
function unsubscribe(normalizedKey: string): void {
const entry = livePaths.get(normalizedKey);
if (!entry) return;
entry.refCount--;
if (entry.refCount <= 0) {
livePaths.delete(normalizedKey);
}
stopPollingIfEmpty();
}
// =============================================================================
// Hook
// =============================================================================
/**
* Sync git branch data for the given project paths into the store.
*
* @param paths - Raw project paths to resolve branches for
* @param options.live - When true, keeps polling every 6s while mounted
*/
export function useBranchSync(paths: string[], options?: { live?: boolean }): void {
const live = options?.live ?? false;
const fetchBranches = useStore((s) => s.fetchBranches);
// Deduplicate and normalize paths into [normalizedKey, actualPath] entries.
// `paths` identity should be stabilized by the caller via useMemo.
const pathEntries = useMemo(() => {
const map = new Map<string, string>();
for (const p of paths) {
const trimmed = p.trim();
if (trimmed) {
const key = normalizePath(trimmed);
if (!map.has(key)) map.set(key, trimmed);
}
}
return Array.from(map.entries());
}, [paths]);
// Stable string key for useEffect deps — avoids re-running on same set of paths
const pathsKey = useMemo(
() =>
pathEntries
.map(([k]) => k)
.sort((a, b) => a.localeCompare(b))
.join('\n'),
[pathEntries]
);
// Initial fetch on mount and whenever paths change (both live and one-shot modes)
useEffect(() => {
if (pathEntries.length === 0) return;
void fetchBranches(pathEntries.map(([, actual]) => actual));
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string derived from pathEntries, avoids re-fetching on array identity change
}, [pathsKey, fetchBranches]);
// Live subscription: register paths with the ref-counted polling manager
useEffect(() => {
if (!live || pathEntries.length === 0) return;
for (const [key, actual] of pathEntries) {
subscribe(key, actual);
}
return () => {
for (const [key] of pathEntries) {
unsubscribe(key);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string key; pathEntries excluded to prevent re-subscribing on array identity change
}, [live, pathsKey]);
}

View file

@ -13,6 +13,7 @@ import { editorBridge } from '@renderer/utils/editorBridge';
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
import { createLogger } from '@shared/utils/logger';
import { getBasename, lastSeparatorIndex, splitPath } from '@shared/utils/platformPath';
import type { AppState } from '../types';
import type {
@ -330,7 +331,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
: null;
if (relative) {
const segments = relative.split('/');
const segments = splitPath(relative);
// Expand each parent directory sequentially (root → child → grandchild).
// Skip the last segment (the file name itself).
// Each expandDirectory call is awaited so that its children are merged
@ -577,7 +578,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return;
}
const fileName = filePath.split('/').pop() ?? 'file';
const fileName = getBasename(filePath) || 'file';
const language = getLanguageFromFileName(fileName);
const tab: EditorFileTab = {
@ -881,7 +882,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
// Refresh parent directory
const parentDir = filePath.substring(0, filePath.lastIndexOf('/'));
const parentDir = filePath.substring(0, lastSeparatorIndex(filePath));
if (parentDir) {
await refreshDirectory(get, set, parentDir);
}
@ -906,7 +907,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try {
const result = await api.editor.moveFile(sourcePath, destDir);
const { newPath, isDirectory } = result;
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
// Record move timestamps for watcher cooldown
recentMoveTimestamps.set(sourcePath, Date.now());
@ -917,7 +918,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const tabs = s.editorOpenTabs.map((tab) => {
const remapped = remapPath(tab.filePath, sourcePath, newPath);
if (remapped === tab.filePath) return tab;
const fileName = remapped.split('/').pop() ?? 'file';
const fileName = getBasename(remapped) || 'file';
return {
...tab,
id: remapped,
@ -1009,7 +1010,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try {
const result = await api.editor.renameFile(sourcePath, newName);
const { newPath, isDirectory } = result;
const parentDir = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
const parentDir = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
recentMoveTimestamps.set(sourcePath, Date.now());
recentMoveTimestamps.set(newPath, Date.now());
@ -1018,7 +1019,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const tabs = s.editorOpenTabs.map((tab) => {
const remapped = remapPath(tab.filePath, sourcePath, newPath);
if (remapped === tab.filePath) return tab;
const fileName = remapped.split('/').pop() ?? 'file';
const fileName = getBasename(remapped) || 'file';
return {
...tab,
id: remapped,
@ -1208,7 +1209,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Refresh parent directory in tree for create/delete
if (event.type === 'create' || event.type === 'delete') {
invalidateQuickOpenCache();
const parentDir = event.path.substring(0, event.path.lastIndexOf('/'));
const parentDir = event.path.substring(0, lastSeparatorIndex(event.path));
if (parentDir && editorProjectPath) {
const existing = dirRefreshDebounceTimers.get(parentDir);
if (existing) clearTimeout(existing);
@ -1295,7 +1296,7 @@ async function refreshDirectory(
const t0 = performance.now();
const result = await api.editor.readDir(dirPath);
log.info(
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop() ?? ''}`
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${getBasename(dirPath)}`
);
const currentTree = get().editorFileTree;
if (!currentTree) return;

View file

@ -116,6 +116,8 @@ export interface TeamSlice {
teamByName: Record<string, TeamSummary>;
/** O(1) lookup: sessionId -> owning team (lead + history) */
teamBySessionId: Record<string, TeamSummary>;
/** Centralized git branch cache: normalizedPath → branch name | null */
branchByPath: Record<string, string | null>;
teamsLoading: boolean;
teamsError: string | null;
globalTasks: GlobalTask[];
@ -141,6 +143,7 @@ export interface TeamSlice {
provisioningError: string | null;
kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null;
fetchBranches: (paths: string[]) => Promise<void>;
fetchTeams: () => Promise<void>;
fetchAllTasks: () => Promise<void>;
openTeamsTab: () => void;
@ -213,6 +216,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teams: [],
teamByName: {},
teamBySessionId: {},
branchByPath: {},
teamsLoading: false,
teamsError: null,
globalTasks: [],
@ -244,6 +248,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deletedTasks: [],
deletedTasksLoading: false,
fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {};
for (const p of paths) {
try {
const branch = await api.teams.getProjectBranch(p);
results[normalizePath(p)] = branch;
} catch {
results[normalizePath(p)] = null;
}
}
if (Object.keys(results).length > 0) {
set((state) => ({ branchByPath: { ...state.branchByPath, ...results } }));
}
},
fetchTeams: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain).
// Only effective during initial load (when teamsLoading is set to true below).

View file

@ -5,6 +5,8 @@
* without pulling in CodeMirror dependencies.
*/
import { getBasename } from '@shared/utils/platformPath';
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
// =============================================================================
@ -66,7 +68,7 @@ export function buildFileAction(
filePath: string,
projectPath?: string | null
): EditorSelectionAction {
const fileName = filePath.split('/').pop() ?? 'file';
const fileName = getBasename(filePath) || 'file';
const displayPath =
projectPath && filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
@ -87,7 +89,7 @@ export function buildSelectionAction(
type: EditorSelectionAction['type'],
info: EditorSelectionInfo
): EditorSelectionAction {
const fileName = info.filePath.split('/').pop() ?? 'file';
const fileName = getBasename(info.filePath) || 'file';
const lang = getCodeFenceLanguage(fileName);
const lineRef =
info.fromLine === info.toLine

View file

@ -4,6 +4,7 @@
import { chipToken } from '@renderer/types/inlineChip';
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
import { getBasename } from '@shared/utils/platformPath';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { EditorSelectionAction } from '@shared/types/editor';
@ -31,7 +32,7 @@ export function createChipFromSelection(
);
if (isDuplicate) return null;
const fileName = action.filePath.split('/').pop() ?? 'file';
const fileName = getBasename(action.filePath) || 'file';
return {
id: `chip-${++chipCounter}-${Date.now()}`,
filePath: action.filePath,
@ -51,7 +52,7 @@ export function createChipFromSelection(
);
if (isDuplicate) return null;
const fileName = action.filePath.split('/').pop() ?? 'file';
const fileName = getBasename(action.filePath) || 'file';
const language = getCodeFenceLanguage(fileName);
return {

View file

@ -7,6 +7,11 @@
* - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions)
*/
import {
lastSeparatorIndex,
splitPath as splitPathCrossPlatform,
} from '@shared/utils/platformPath';
import { extractFileReferences } from './groupTransformer';
import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';
@ -147,26 +152,9 @@ function normalizeSeparators(input: string, separator: '/' | '\\'): string {
return output;
}
/** Local alias — delegates to the shared cross-platform splitPath. */
function splitPath(input: string): string[] {
const parts: string[] = [];
let current = '';
for (const char of input) {
if (char === '/' || char === '\\') {
if (current.length > 0) {
parts.push(current);
current = '';
}
} else {
current += char;
}
}
if (current.length > 0) {
parts.push(current);
}
return parts;
return splitPathCrossPlatform(input);
}
function normalizeForComparison(input: string): string {
@ -177,7 +165,7 @@ function normalizeForComparison(input: string): string {
* Get the directory containing a file.
*/
export function getDirectory(filePath: string): string {
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const lastSep = lastSeparatorIndex(filePath);
if (lastSep === -1) return '';
return filePath.slice(0, lastSep);
}
@ -186,7 +174,7 @@ export function getDirectory(filePath: string): string {
* Get the parent directory of a path.
*/
export function getParentDirectory(dirPath: string): string | null {
const lastSep = Math.max(dirPath.lastIndexOf('/'), dirPath.lastIndexOf('\\'));
const lastSep = lastSeparatorIndex(dirPath);
if (lastSep <= 0) return null; // At root or invalid
return dirPath.slice(0, lastSep);
}

View file

@ -5,6 +5,8 @@
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
*/
import { splitPath as splitPathCrossPlatform } from '@shared/utils/platformPath';
export interface TreeNode<T> {
name: string;
fullPath: string;
@ -28,7 +30,7 @@ export function buildTree<T>(
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
for (const item of items) {
const parts = getPath(item).split('/');
const parts = splitPathCrossPlatform(getPath(item));
let current = root;
for (let i = 0; i < parts.length; i++) {

View file

@ -9,6 +9,8 @@
* Also provides resolveAbsolutePath() for clipboard copy (~ real home, relative absolute).
*/
import { splitPath } from '@shared/utils/platformPath';
/**
* Shorten a file path for display in compact UI elements.
* Full path should still be available via tooltip (title attribute).
@ -82,7 +84,7 @@ export function formatProjectPath(path: string): string {
const p = path.replace(/\\/g, '/');
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
const parts = p.split('/').filter(Boolean);
const parts = splitPath(p);
if (parts.length >= 2) {
const rest = parts.slice(2).join('/');
return rest ? `~/${rest}` : '~';
@ -90,7 +92,7 @@ export function formatProjectPath(path: string): string {
}
if (isWindowsUserPath(path)) {
const parts = p.split('/').filter(Boolean);
const parts = splitPath(p);
if (parts.length >= 3) {
const rest = parts.slice(3).join('/');
return rest ? `~/${rest}` : '~';

View file

@ -8,6 +8,8 @@
* 4. Unique file names get no label (disambiguatedLabel = undefined)
*/
import { splitPath } from '@shared/utils/platformPath';
import type { EditorFileTab } from '@shared/types/editor';
/**
@ -49,7 +51,7 @@ export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[]
// Split paths into segments for comparison
const pathSegments = group.map((tab) => {
const parts = tab.filePath.split('/');
const parts = splitPath(tab.filePath);
// Remove the file name (last segment)
parts.pop();
return parts;

View file

@ -1,4 +1,5 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import { splitPath } from '@shared/utils/platformPath';
import { differenceInDays, isToday, isYesterday } from 'date-fns';
import { DATE_CATEGORY_ORDER } from '../types/tabs';
@ -101,10 +102,7 @@ function trimTrailingPathSep(p: string): string {
export function projectLabelFromPath(path: string): string {
const normalized = trimTrailingPathSep(path);
const segments = normalized
.split('/')
.flatMap((s) => s.split('\\'))
.filter(Boolean);
const segments = splitPath(normalized);
return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL;
}

View file

@ -0,0 +1,31 @@
/**
* Cross-platform path utilities for the renderer process.
*
* Node's `path` module is unavailable in the renderer, and incoming paths
* may originate from any OS (Unix `/` or Windows `\`). Every helper here
* handles both separators transparently.
*/
const SEP_RE = /[/\\]/;
/** Split a file path on both `/` and `\` separators. */
export function splitPath(filePath: string): string[] {
return filePath.split(SEP_RE).filter(Boolean);
}
/** Get the last segment (filename) from a path. */
export function getBasename(filePath: string): string {
const parts = splitPath(filePath);
return parts[parts.length - 1] ?? '';
}
/** Get directory part of a path (everything before the last separator). */
export function getDirname(filePath: string): string {
const lastSep = lastSeparatorIndex(filePath);
return lastSep === -1 ? '' : filePath.substring(0, lastSep);
}
/** Find the last path separator index (handles both `/` and `\`). */
export function lastSeparatorIndex(filePath: string): number {
return Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
}