feat(FN-2944): merge fusion/fn-2944

- test(FN-2944): cover already checked out worktree conflict recovery
- fix(FN-2944): recognize git already checked out worktree conflict
- fix(engine): auto-recover from squash-merge orphan rebase failures

Fusion-Task-Id: FN-2944
This commit is contained in:
Fusion
2026-04-29 07:10:52 -07:00
committed by gsxdsm
parent 98fb71c202
commit 995165ea60
46 changed files with 454 additions and 163 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
import { browseDirectory, type BrowseDirectoryResult } from "../api";
import { getPathBreadcrumbs } from "../utils/pathDisplay";
import "./DirectoryPicker.css";
export interface DirectoryPickerProps {
@@ -99,9 +100,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
}
}, [browser.showHidden]);
const breadcrumbs = browser.currentPath
? browser.currentPath.split("/").filter(Boolean)
: [];
const breadcrumbs = browser.currentPath ? getPathBreadcrumbs(browser.currentPath) : [];
return (
<div className="directory-picker">
@@ -129,26 +128,17 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
{/* Breadcrumbs */}
<div className="directory-picker-breadcrumbs">
<button
type="button"
className="directory-picker-breadcrumb"
onClick={() => handleNavigate("/")}
title="Root"
>
/
</button>
{breadcrumbs.map((segment, i) => {
const segPath = "/" + breadcrumbs.slice(0, i + 1).join("/");
{breadcrumbs.map((breadcrumb, index) => {
return (
<span key={segPath} className="directory-picker-breadcrumb-item">
<ChevronRight size={12} className="directory-picker-breadcrumb-sep" />
<span key={breadcrumb.path} className="directory-picker-breadcrumb-item">
{index > 0 && <ChevronRight size={12} className="directory-picker-breadcrumb-sep" />}
<button
type="button"
className="directory-picker-breadcrumb"
onClick={() => handleNavigate(segPath)}
title={segPath}
onClick={() => handleNavigate(breadcrumb.path)}
title={breadcrumb.path}
>
{segment}
{breadcrumb.label}
</button>
</span>
);

View File

@@ -5,6 +5,7 @@ import type { FileNode } from "../api";
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
import { appendTokenQuery } from "../auth";
import { getErrorMessage } from "@fusion/core";
import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay";
interface FileBrowserProps {
entries: FileNode[];
@@ -37,7 +38,7 @@ function formatTime(mtime?: string): string {
/** Build the full relative path for a file/directory entry */
function entryPath(currentPath: string, name: string): string {
return currentPath === "." ? name : `${currentPath}/${name}`;
return joinDisplayPath(currentPath, name);
}
// ── Context Menu State ──────────────────────────────────────────────────
@@ -550,16 +551,14 @@ export function FileBrowser({
<button
className="file-browser-up"
onClick={() => {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
onNavigate(parts.length === 0 ? "." : parts.join("/"));
onNavigate(getParentDisplayPath(currentPath));
}}
>
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
Up one level
</button>
)}
<span className="file-browser-path">{currentPath === "." ? "Root" : currentPath}</span>
<span className="file-browser-path">{currentPath === "." ? "Root" : normalizeDisplayPath(currentPath)}</span>
</div>
<div className="file-browser-list">

View File

@@ -1,5 +1,6 @@
import { File } from "lucide-react";
import type { FileSearchItem } from "../hooks/useFileMention";
import { getDisplayDirname } from "../utils/pathDisplay";
import "./FileMentionPopup.css";
import type { ReactNode } from "react";
@@ -54,7 +55,7 @@ export function FileMentionPopup({
{!loading && files.length > 0 && (
<ul className="file-mention-popup-list" role="listbox">
{files.map((file, index) => {
const dirPath = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/") + 1) : "";
const dirPath = getDisplayDirname(file.path);
const highlightName = file.name;
return (
@@ -85,4 +86,4 @@ export function FileMentionPopup({
)}
</div>
);
}
}

View File

@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { truncateMiddle } from "../utils/truncatePath";
import { getPathBasename } from "../utils/pathDisplay";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type {
@@ -1624,7 +1625,7 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
{worktree.isMain && <span className="gm-badge main">main</span>}
{worktree.isBare && <span className="gm-badge bare">bare</span>}
<span className="gm-worktree-path" title={worktree.path}>
{worktree.path.split("/").pop() || worktree.path}
{getPathBasename(worktree.path) || worktree.path}
</span>
</div>
<div className="gm-worktree-detail">

View File

@@ -10,6 +10,7 @@ import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { NodeHealthDot } from "./NodeHealthDot";
import { PluginSlot } from "./PluginSlot";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
import { getTrailingPath } from "../utils/pathDisplay";
export { useViewportMode };
@@ -114,7 +115,7 @@ function ProjectSelector({
<div className="project-selector-info">
<span className="project-selector-name">{project.name}</span>
<span className="project-selector-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="project-selector-check" />}
@@ -874,7 +875,7 @@ export function Header({
<div className="mobile-project-switch-info">
<span className="mobile-project-switch-name">{project.name}</span>
<span className="mobile-project-switch-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}

View File

@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { getTrailingPath } from "../utils/pathDisplay";
export interface ProjectSelectorProps {
projects: ProjectInfo[];
@@ -354,7 +355,7 @@ export function ProjectSelector({
{project.name}
</span>
<span className="project-selector__item-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{currentProject?.id === project.id && (

View File

@@ -13,6 +13,7 @@ import {
import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { getPathBasename } from "../utils/pathDisplay";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
@@ -750,7 +751,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
const unsubConnect = onConnect((info) => {
// Update tab title with shell name
updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell);
updateTabTitle(activeTab.id, getPathBasename(info.shell) || info.shell);
});
const unsubExit = onExit((code) => {

View File

@@ -0,0 +1,92 @@
export interface PathBreadcrumb {
label: string;
path: string;
}
export function normalizeDisplayPath(path: string): string {
return path.replace(/\\/g, "/");
}
function trimTrailingSeparators(path: string): string {
return path.replace(/[\\/]+$/g, "");
}
export function splitPathSegments(path: string): string[] {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
if (!normalized) return [];
return normalized.split("/").filter(Boolean);
}
export function getPathBasename(path: string): string {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
if (!normalized) return path;
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || normalized;
}
export function getTrailingPath(path: string, count: number): string {
const segments = splitPathSegments(path);
if (segments.length === 0) {
return normalizeDisplayPath(path);
}
return segments.slice(-count).join("/");
}
export function getDisplayDirname(path: string): string {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
const lastSeparator = normalized.lastIndexOf("/");
if (lastSeparator < 0) return "";
return normalized.slice(0, lastSeparator + 1);
}
export function joinDisplayPath(basePath: string, name: string): string {
if (!basePath || basePath === ".") {
return name;
}
const segments = splitPathSegments(basePath);
return [...segments, name].join("/");
}
export function getParentDisplayPath(path: string): string {
const segments = splitPathSegments(path);
if (segments.length === 0) {
return ".";
}
segments.pop();
return segments.length === 0 ? "." : segments.join("/");
}
export function getPathBreadcrumbs(path: string): PathBreadcrumb[] {
const normalized = normalizeDisplayPath(path);
const winMatch = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (winMatch) {
const root = `${winMatch[1]}/`;
const segments = (winMatch[2] ?? "").split("/").filter(Boolean);
const breadcrumbs: PathBreadcrumb[] = [{ label: winMatch[1], path: root }];
for (let i = 0; i < segments.length; i += 1) {
breadcrumbs.push({
label: segments[i]!,
path: `${root}${segments.slice(0, i + 1).join("/")}`,
});
}
return breadcrumbs;
}
if (normalized.startsWith("/")) {
const segments = normalized.split("/").filter(Boolean);
const breadcrumbs: PathBreadcrumb[] = [{ label: "/", path: "/" }];
for (let i = 0; i < segments.length; i += 1) {
breadcrumbs.push({
label: segments[i]!,
path: `/${segments.slice(0, i + 1).join("/")}`,
});
}
return breadcrumbs;
}
const segments = normalized.split("/").filter(Boolean);
return segments.map((segment, index) => ({
label: segment,
path: segments.slice(0, index + 1).join("/"),
}));
}

View File

@@ -1,4 +1,5 @@
import type { Task } from "@fusion/core";
import { getPathBasename } from "./pathDisplay";
export interface WorktreeGroupData {
label: string;
@@ -11,9 +12,7 @@ export interface WorktreeGroupData {
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
// Take the last segment of the path
const segments = worktreePath.replace(/\/+$/, "").split("/");
return segments[segments.length - 1] || worktreePath;
return getPathBasename(worktreePath) || worktreePath;
}
/**