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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
92
packages/dashboard/app/utils/pathDisplay.ts
Normal file
92
packages/dashboard/app/utils/pathDisplay.ts
Normal 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("/"),
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user