feat: add file manager context actions (#3027)
* feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
a98d154b6b
commit
ca2b633286
@@ -1,6 +1,20 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
Copy,
|
||||
CopyPlus,
|
||||
Download,
|
||||
FilePlus,
|
||||
FileText,
|
||||
FolderMinus,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
MessageSquarePlus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Undo2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
@@ -10,11 +24,14 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
interface FileAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onSelect: () => void;
|
||||
destructive?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps {
|
||||
fileExists?: boolean;
|
||||
onOpenFile?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onCopyRelativePath?: () => void;
|
||||
onReveal?: () => void;
|
||||
revealTargetName?: string;
|
||||
onDownload?: () => void;
|
||||
onAddToChat?: () => void;
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onCollapseFolder?: () => void;
|
||||
onRename?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onRevert?: () => void;
|
||||
onDelete?: () => void;
|
||||
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||
header?: ReactNode;
|
||||
testIDPrefix?: string;
|
||||
@@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({
|
||||
fileExists = true,
|
||||
onOpenFile,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDownload,
|
||||
onAddToChat,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCollapseFolder,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
onDelete,
|
||||
header,
|
||||
testIDPrefix,
|
||||
}: FileActionsContextMenuContentProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const actions = useMemo<FileAction[]>(() => {
|
||||
const availableFile = fileKind === "file" && fileExists;
|
||||
const next: FileAction[] = [];
|
||||
if (availableFile && onOpenFile) {
|
||||
next.push({
|
||||
key: "open-file",
|
||||
label: t("workspace.fileActions.openFile"),
|
||||
icon: FileText,
|
||||
onSelect: onOpenFile,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
||||
});
|
||||
}
|
||||
if (onCopyPath) {
|
||||
next.push({
|
||||
key: "copy-path",
|
||||
label: t("workspace.fileActions.copyPath"),
|
||||
icon: Copy,
|
||||
onSelect: onCopyPath,
|
||||
});
|
||||
}
|
||||
if (availableFile && onDownload) {
|
||||
next.push({
|
||||
key: "download",
|
||||
label: t("workspace.fileActions.download"),
|
||||
icon: Download,
|
||||
onSelect: onDownload,
|
||||
});
|
||||
}
|
||||
if (availableFile && onAddToChat) {
|
||||
next.push({
|
||||
key: "add-to-chat",
|
||||
label: t("workspace.fileActions.addToChat"),
|
||||
icon: MessageSquarePlus,
|
||||
onSelect: onAddToChat,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
||||
const specs: Array<FileAction | null> = [
|
||||
onNewFile
|
||||
? {
|
||||
key: "new-file",
|
||||
label: t("workspace.fileActions.newFile"),
|
||||
icon: FilePlus,
|
||||
onSelect: onNewFile,
|
||||
}
|
||||
: null,
|
||||
onNewFolder
|
||||
? {
|
||||
key: "new-folder",
|
||||
label: t("workspace.fileActions.newFolder"),
|
||||
icon: FolderPlus,
|
||||
onSelect: onNewFolder,
|
||||
}
|
||||
: null,
|
||||
onCollapseFolder
|
||||
? {
|
||||
key: "collapse-folder",
|
||||
label: t("workspace.fileActions.collapseFolder"),
|
||||
icon: FolderMinus,
|
||||
onSelect: onCollapseFolder,
|
||||
}
|
||||
: null,
|
||||
availableFile && onOpenFile
|
||||
? {
|
||||
key: "open-file",
|
||||
label: t("workspace.fileActions.openFile"),
|
||||
icon: FileText,
|
||||
onSelect: onOpenFile,
|
||||
}
|
||||
: null,
|
||||
onRename
|
||||
? {
|
||||
key: "rename",
|
||||
label: t("workspace.fileActions.rename"),
|
||||
icon: Pencil,
|
||||
onSelect: onRename,
|
||||
}
|
||||
: null,
|
||||
onDuplicate
|
||||
? {
|
||||
key: "duplicate",
|
||||
label: t("workspace.fileActions.duplicate"),
|
||||
icon: CopyPlus,
|
||||
onSelect: onDuplicate,
|
||||
}
|
||||
: null,
|
||||
onCopyPath
|
||||
? {
|
||||
key: "copy-path",
|
||||
label: t("workspace.fileActions.copyPath"),
|
||||
icon: Copy,
|
||||
onSelect: onCopyPath,
|
||||
}
|
||||
: null,
|
||||
onCopyRelativePath
|
||||
? {
|
||||
key: "copy-relative-path",
|
||||
label: t("workspace.fileActions.copyRelativePath"),
|
||||
icon: Copy,
|
||||
onSelect: onCopyRelativePath,
|
||||
}
|
||||
: null,
|
||||
onReveal && revealTargetName
|
||||
? {
|
||||
key: "reveal",
|
||||
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
|
||||
icon: FolderOpen,
|
||||
onSelect: onReveal,
|
||||
}
|
||||
: null,
|
||||
availableFile && onDownload
|
||||
? {
|
||||
key: "download",
|
||||
label: t("workspace.fileActions.download"),
|
||||
icon: Download,
|
||||
onSelect: onDownload,
|
||||
}
|
||||
: null,
|
||||
availableFile && onAddToChat
|
||||
? {
|
||||
key: "add-to-chat",
|
||||
label: t("workspace.fileActions.addToChat"),
|
||||
icon: MessageSquarePlus,
|
||||
onSelect: onAddToChat,
|
||||
}
|
||||
: null,
|
||||
onRevert
|
||||
? {
|
||||
key: "revert",
|
||||
label: t("workspace.fileActions.revert"),
|
||||
icon: Undo2,
|
||||
onSelect: onRevert,
|
||||
destructive: true,
|
||||
}
|
||||
: null,
|
||||
onDelete
|
||||
? {
|
||||
key: "delete",
|
||||
label: t("workspace.fileActions.delete"),
|
||||
icon: Trash2,
|
||||
onSelect: onDelete,
|
||||
destructive: true,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const availableActions = specs.filter((action): action is FileAction => action !== null);
|
||||
return availableActions.map((action, index) =>
|
||||
Object.assign(action, {
|
||||
separatorBefore: Boolean(
|
||||
action.destructive && index > 0 && !availableActions[index - 1].destructive,
|
||||
),
|
||||
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
fileExists,
|
||||
fileKind,
|
||||
onAddToChat,
|
||||
onCollapseFolder,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onDuplicate,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onOpenFile,
|
||||
onRename,
|
||||
onReveal,
|
||||
onRevert,
|
||||
revealTargetName,
|
||||
t,
|
||||
testIDPrefix,
|
||||
]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
@@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({
|
||||
</>
|
||||
) : null}
|
||||
{actions.map((action) => (
|
||||
<FileActionMenuItem key={action.key} action={action} />
|
||||
<Fragment key={action.key}>
|
||||
{action.separatorBefore ? <ContextMenuSeparator /> : null}
|
||||
<FileActionMenuItem action={action} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||
const Icon = action.icon;
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
||||
[ThemedIcon],
|
||||
);
|
||||
const leading = useMemo(() => {
|
||||
const ThemedIcon = withUnistyles(action.icon);
|
||||
return (
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={action.destructive ? destructiveColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}, [action.destructive, action.icon]);
|
||||
return (
|
||||
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||
<ContextMenuItem
|
||||
leading={leading}
|
||||
onSelect={action.onSelect}
|
||||
destructive={action.destructive}
|
||||
testID={action.testID}
|
||||
>
|
||||
{action.label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// paths) render different data, but their ROWS should look identical — same
|
||||
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
|
||||
export const TREE_INDENT_PER_LEVEL = 14;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
|
||||
export const WORKSPACE_TREE_ICON_SIZE = 14;
|
||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
|
||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
|
||||
/**
|
||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
||||
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||
* header icons' strokes extend to theirs.
|
||||
*/
|
||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
|
||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
export function treeRowPaddingLeft(depth: number): number {
|
||||
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
||||
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL;
|
||||
}
|
||||
|
||||
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
||||
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||
key: index,
|
||||
style: [
|
||||
styles.indentGuide,
|
||||
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
|
||||
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
|
||||
],
|
||||
})),
|
||||
[depth],
|
||||
@@ -63,13 +63,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
expanded
|
||||
? [styles.chevron, styles.chevronExpanded]
|
||||
: [styles.chevron, styles.chevronCollapsed]
|
||||
}
|
||||
>
|
||||
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
|
||||
<ThemedChevronRight
|
||||
size={WORKSPACE_TREE_ICON_SIZE}
|
||||
uniProps={foregroundExtraMutedIconColorMapping}
|
||||
@@ -93,13 +87,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
|
||||
// slot; the rotated glyph is wider, so it needs half the optical offset.
|
||||
chevronCollapsed: {
|
||||
left: 4,
|
||||
},
|
||||
chevronExpanded: {
|
||||
left: 2,
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -105,6 +105,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile = true,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
onContextMenu,
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
@@ -115,6 +116,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile?: boolean;
|
||||
enabledOnWeb?: boolean;
|
||||
longPressDelayMs?: number;
|
||||
onContextMenu?: (event: unknown) => void;
|
||||
triggerRef?: Ref<View | null>;
|
||||
}
|
||||
>): ReactElement {
|
||||
@@ -170,9 +172,10 @@ export function ContextMenuTrigger({
|
||||
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||
}
|
||||
onContextMenu?.(event);
|
||||
openAtEvent(event);
|
||||
},
|
||||
[openAtEvent],
|
||||
[onContextMenu, openAtEvent],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
|
||||
@@ -333,6 +333,7 @@ export function AnchoredSurface({
|
||||
<>
|
||||
{backdrop ? (
|
||||
<Pressable
|
||||
{...{ onContextMenu: onClose }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
@@ -413,6 +414,9 @@ export function MenuOverlay({
|
||||
const overlay = (
|
||||
<OverlayLayerProvider layer={floatingLayer}>
|
||||
<View
|
||||
{...{
|
||||
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||
}}
|
||||
ref={setWebOverlayScope}
|
||||
collapsable={false}
|
||||
style={[
|
||||
|
||||
@@ -183,6 +183,31 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("discards selected paths through the shared checkout action workflow", async () => {
|
||||
const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null }));
|
||||
const client = { checkoutDiscardChanges };
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] });
|
||||
|
||||
expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, {
|
||||
paths: ["renamed.ts", "original.ts"],
|
||||
});
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "discard-changes" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
for (const rpc of [
|
||||
{
|
||||
label: "forge",
|
||||
|
||||
@@ -24,7 +24,8 @@ export type CheckoutGitAsyncActionId =
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base";
|
||||
| "merge-from-base"
|
||||
| "discard-changes";
|
||||
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
@@ -119,6 +120,7 @@ interface CheckoutGitActionsStoreState {
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
async function runCheckoutAction({
|
||||
@@ -366,6 +368,23 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
discardChanges: async ({ serverId, cwd, paths }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "discard-changes",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutDiscardChanges(cwd, { paths });
|
||||
if (!payload.success) {
|
||||
throw new Error(
|
||||
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function __resetCheckoutGitActionsStoreForTests() {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
TreeChevron,
|
||||
@@ -15,9 +10,16 @@ import {
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||
WORKSPACE_TREE_ICON_SIZE,
|
||||
} from "@/components/tree-primitives";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface DiffFolderRowProps {
|
||||
/** full uncompressed directory path — the collapse identity */
|
||||
@@ -25,20 +27,27 @@ interface DiffFolderRowProps {
|
||||
displayName: string;
|
||||
depth: number;
|
||||
collapsed: boolean;
|
||||
isSelected: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
onToggle: (dirPath: string) => void;
|
||||
onCollapse: (dirPath: string) => void;
|
||||
onSelect: (dirPath: string) => void;
|
||||
onHeightChange?: (height: number) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
revealTargetName?: string;
|
||||
onDuplicate?: (path: string) => void;
|
||||
onRevert?: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function folderRowPressableStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
// Subtle background highlight on hover/press, matching the Files explorer rows
|
||||
// (entryRowActive) — no opacity darken.
|
||||
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
|
||||
function folderRowPressableStyle(
|
||||
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||
isSelected: boolean,
|
||||
) {
|
||||
return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive];
|
||||
}
|
||||
|
||||
export function DiffFolderRow({
|
||||
@@ -46,15 +55,39 @@ export function DiffFolderRow({
|
||||
displayName,
|
||||
depth,
|
||||
collapsed,
|
||||
isSelected,
|
||||
additions,
|
||||
deletions,
|
||||
onToggle,
|
||||
onCollapse,
|
||||
onSelect,
|
||||
onHeightChange,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
testID,
|
||||
}: DiffFolderRowProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect(dirPath);
|
||||
}, [dirPath, onSelect]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
const selection = isWeb ? window.getSelection() : null;
|
||||
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||
return;
|
||||
}
|
||||
handleSelect();
|
||||
onToggle(dirPath);
|
||||
}, [dirPath, onToggle]);
|
||||
}, [dirPath, handleSelect, onToggle]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
folderRowPressableStyle(state, isSelected),
|
||||
[isSelected],
|
||||
);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
@@ -63,39 +96,88 @@ export function DiffFolderRow({
|
||||
[onHeightChange],
|
||||
);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
onCollapse(dirPath);
|
||||
}, [dirPath, onCollapse]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
onCopyPath?.(dirPath);
|
||||
}, [dirPath, onCopyPath]);
|
||||
|
||||
const handleCopyRelativePath = useCallback(() => {
|
||||
onCopyRelativePath?.(dirPath);
|
||||
}, [dirPath, onCopyRelativePath]);
|
||||
|
||||
const handleReveal = useCallback(() => {
|
||||
onReveal?.(dirPath);
|
||||
}, [dirPath, onReveal]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
onDuplicate?.(dirPath);
|
||||
}, [dirPath, onDuplicate]);
|
||||
|
||||
const handleRevert = useCallback(() => {
|
||||
onRevert?.(dirPath);
|
||||
}, [dirPath, onRevert]);
|
||||
|
||||
const leftStyle = useMemo(
|
||||
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
|
||||
[depth],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ expanded: !collapsed, selected: isSelected }),
|
||||
[collapsed, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={folderRowPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
>
|
||||
<View style={leftStyle}>
|
||||
<View style={styles.chevronOpticalOffset}>
|
||||
<TreeChevron expanded={!collapsed} />
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
onPress={handlePress}
|
||||
onLongPress={handleSelect}
|
||||
onContextMenu={handleSelect}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
>
|
||||
<View style={leftStyle}>
|
||||
<View style={styles.chevronOpticalOffset}>
|
||||
<TreeChevron expanded={!collapsed} />
|
||||
</View>
|
||||
<View style={styles.folderIcon}>
|
||||
<ThemedFolder
|
||||
size={WORKSPACE_TREE_ICON_SIZE}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.folderName} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.folderName} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<DiffStat
|
||||
additions={additions}
|
||||
deletions={deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View style={styles.right}>
|
||||
<DiffStat
|
||||
additions={additions}
|
||||
deletions={deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
</View>
|
||||
</ContextMenuTrigger>
|
||||
<FileActionsContextMenuContent
|
||||
fileKind="directory"
|
||||
onCollapseFolder={!collapsed ? handleCollapse : undefined}
|
||||
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
|
||||
onReveal={onReveal ? handleReveal : undefined}
|
||||
revealTargetName={revealTargetName}
|
||||
onDuplicate={onDuplicate ? handleDuplicate : undefined}
|
||||
onRevert={onRevert ? handleRevert : undefined}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -123,8 +205,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
chevronOpticalOffset: {
|
||||
// The Changes directory chevron reads high beside the folder label.
|
||||
transform: [{ translateY: 2 }],
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
folderIcon: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
right: {
|
||||
flexDirection: "row",
|
||||
@@ -138,5 +228,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -81,6 +81,7 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
@@ -94,6 +95,7 @@ import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -119,6 +121,7 @@ import {
|
||||
} from "@/review";
|
||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -129,11 +132,14 @@ export function resolveDiffLayout(
|
||||
return canUseSplitLayout ? layout : "unified";
|
||||
}
|
||||
|
||||
function fileHeaderPressableStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive];
|
||||
function fileHeaderPressableStyle(
|
||||
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||
isSelected: boolean,
|
||||
) {
|
||||
return [
|
||||
styles.fileHeader,
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive,
|
||||
];
|
||||
}
|
||||
|
||||
interface HighlightedTextProps {
|
||||
@@ -208,16 +214,23 @@ interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
isExpanded: boolean;
|
||||
isSelected?: boolean;
|
||||
/** Tree indentation level (0 on the flat/mobile path). */
|
||||
depth?: number;
|
||||
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
||||
showDir?: boolean;
|
||||
interactive?: boolean;
|
||||
onToggle?: (path: string) => void;
|
||||
onSelect?: (path: string) => void;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
revealTargetName?: string;
|
||||
onDownload?: (path: string) => void;
|
||||
onDuplicate?: (path: string) => void;
|
||||
onRevert?: (path: string, oldPath?: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -226,6 +239,57 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
||||
|
||||
function noopStartComment(): void {}
|
||||
|
||||
function useDiscardChangesAction({
|
||||
serverId,
|
||||
cwd,
|
||||
diffMode,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
diffMode: "uncommitted" | "base";
|
||||
}): ((path: string, oldPath?: string) => void) | undefined {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const discardSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
|
||||
);
|
||||
const discardPath = useCallback(
|
||||
async (path: string, oldPath?: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("workspace.fileActions.confirmRevert.title"),
|
||||
message: t("workspace.fileActions.confirmRevert.message", { name: path }),
|
||||
confirmLabel: t("workspace.fileActions.confirmRevert.confirm"),
|
||||
cancelLabel: t("workspace.fileActions.confirmRevert.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await discardChanges({
|
||||
serverId,
|
||||
cwd,
|
||||
paths: oldPath ? [path, oldPath] : [path],
|
||||
});
|
||||
} catch (cause) {
|
||||
toast.error(
|
||||
cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"),
|
||||
);
|
||||
}
|
||||
},
|
||||
[cwd, discardChanges, serverId, t, toast],
|
||||
);
|
||||
const handleDiscardPath = useCallback(
|
||||
(path: string, oldPath?: string) => {
|
||||
void discardPath(path, oldPath);
|
||||
},
|
||||
[discardPath],
|
||||
);
|
||||
return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined;
|
||||
}
|
||||
|
||||
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
||||
|
||||
function LongPressableLine({
|
||||
@@ -247,6 +311,10 @@ function LongPressableLine({
|
||||
}) {
|
||||
const onStartComment = reviewActions?.onStartComment;
|
||||
const handlePress = useCallback(() => {
|
||||
const selection = isWeb ? window.getSelection() : null;
|
||||
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||
return;
|
||||
}
|
||||
if (reviewTarget && onStartComment) {
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
@@ -912,18 +980,84 @@ function SplitDiffColumn({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffFileActionsContextMenuContent({
|
||||
file,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDownload,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
testID,
|
||||
}: Pick<
|
||||
DiffFileSectionProps,
|
||||
| "file"
|
||||
| "onOpenFile"
|
||||
| "onAddToChat"
|
||||
| "onCopyPath"
|
||||
| "onCopyRelativePath"
|
||||
| "onReveal"
|
||||
| "revealTargetName"
|
||||
| "onDownload"
|
||||
| "onDuplicate"
|
||||
| "onRevert"
|
||||
| "testID"
|
||||
>) {
|
||||
const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
|
||||
const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
|
||||
const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
|
||||
const handleCopyRelativePath = useCallback(
|
||||
() => onCopyRelativePath?.(file.path),
|
||||
[file.path, onCopyRelativePath],
|
||||
);
|
||||
const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]);
|
||||
const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]);
|
||||
const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]);
|
||||
const handleRevert = useCallback(
|
||||
() => onRevert?.(file.path, file.oldPath),
|
||||
[file.oldPath, file.path, onRevert],
|
||||
);
|
||||
|
||||
return (
|
||||
<FileActionsContextMenuContent
|
||||
fileKind="file"
|
||||
fileExists={!file.isDeleted}
|
||||
onOpenFile={onOpenFile ? handleOpenFile : undefined}
|
||||
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
|
||||
onReveal={onReveal ? handleReveal : undefined}
|
||||
revealTargetName={revealTargetName}
|
||||
onDownload={onDownload ? handleDownload : undefined}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
onDuplicate={!file.isDeleted && onDuplicate ? handleDuplicate : undefined}
|
||||
onRevert={onRevert ? handleRevert : undefined}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DiffFileHeader = memo(function DiffFileHeader({
|
||||
file,
|
||||
workspaceFileDragScope,
|
||||
isExpanded,
|
||||
isSelected = false,
|
||||
depth = 0,
|
||||
showDir = true,
|
||||
interactive = true,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDownload,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
}: DiffFileSectionProps) {
|
||||
@@ -938,29 +1072,24 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
const pressHandledRef = useRef(false);
|
||||
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (interactive) {
|
||||
onSelect?.(file.path);
|
||||
}
|
||||
}, [file.path, interactive, onSelect]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
const selection = isWeb ? window.getSelection() : null;
|
||||
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||
return;
|
||||
}
|
||||
pressHandledRef.current = true;
|
||||
handleSelect();
|
||||
onToggle?.(file.path);
|
||||
}, [file.path, interactive, onToggle]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
onOpenFile?.(file.path);
|
||||
}, [file.path, onOpenFile]);
|
||||
|
||||
const handleAddToChat = useCallback(() => {
|
||||
onAddToChat?.(file.path);
|
||||
}, [file.path, onAddToChat]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
onCopyPath?.(file.path);
|
||||
}, [file.path, onCopyPath]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
onDownload?.(file.path);
|
||||
}, [file.path, onDownload]);
|
||||
}, [file.path, handleSelect, interactive, onToggle]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
@@ -981,7 +1110,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
pressHandledRef.current = true;
|
||||
}, []);
|
||||
handleSelect();
|
||||
}, [handleSelect]);
|
||||
|
||||
const handlePressOut = useCallback(
|
||||
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
@@ -1008,16 +1138,20 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
|
||||
[isExpanded],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ expanded: isExpanded, selected: isSelected }),
|
||||
[isExpanded, isSelected],
|
||||
);
|
||||
|
||||
const headerPressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType) =>
|
||||
depth > 0
|
||||
? [
|
||||
fileHeaderPressableStyle(state),
|
||||
fileHeaderPressableStyle(state, isSelected),
|
||||
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
||||
]
|
||||
: fileHeaderPressableStyle(state),
|
||||
[depth],
|
||||
: fileHeaderPressableStyle(state, isSelected),
|
||||
[depth, isSelected],
|
||||
);
|
||||
|
||||
const fileName = file.path.split("/").pop() ?? file.path;
|
||||
@@ -1027,9 +1161,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
ref={dragSourceRef}
|
||||
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
|
||||
>
|
||||
{showDir ? null : <View style={styles.fileChevronSpacer} />}
|
||||
{showDir ? null : (
|
||||
<View style={styles.fileIcon}>
|
||||
<MaterialFileIcon fileName={fileName} size={16} />
|
||||
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
@@ -1060,7 +1195,14 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
let trigger: ReactElement;
|
||||
if (!interactive) {
|
||||
trigger = (
|
||||
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
|
||||
<View
|
||||
{...{
|
||||
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||
}}
|
||||
style={headerPressableStyle({ hovered: false, pressed: false })}
|
||||
>
|
||||
{headerContent}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
trigger = (
|
||||
@@ -1072,7 +1214,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onLongPress={handleLongPress}
|
||||
onContextMenu={handleSelect}
|
||||
onPress={toggleExpanded}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{headerContent}
|
||||
</ContextMenuTrigger>
|
||||
@@ -1090,14 +1235,18 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{interactive ? (
|
||||
<FileActionsContextMenuContent
|
||||
fileKind="file"
|
||||
fileExists={!file.isDeleted}
|
||||
onOpenFile={onOpenFile ? handleOpenFile : undefined}
|
||||
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||
onDownload={onDownload ? handleDownload : undefined}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
testIDPrefix={testID}
|
||||
<DiffFileActionsContextMenuContent
|
||||
file={file}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={onAddToChat}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
onReveal={onReveal}
|
||||
revealTargetName={revealTargetName}
|
||||
onDownload={onDownload}
|
||||
onDuplicate={onDuplicate}
|
||||
onRevert={onRevert}
|
||||
testID={testID}
|
||||
/>
|
||||
) : null}
|
||||
</ContextMenu>
|
||||
@@ -1827,7 +1976,12 @@ interface SharedDiffViewProps {
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
revealTargetName?: string;
|
||||
onDownload?: (path: string) => void;
|
||||
onDuplicate?: (path: string) => void;
|
||||
onRevert?: (path: string, oldPath?: string) => void;
|
||||
onExpandedPathsChange: (paths: string[]) => void;
|
||||
onCollapsedFoldersChange: (paths: string[]) => void;
|
||||
}
|
||||
@@ -1882,10 +2036,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
const workspaceFileDragScope =
|
||||
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
|
||||
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
|
||||
const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined;
|
||||
const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined;
|
||||
const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined;
|
||||
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
|
||||
const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined;
|
||||
const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined;
|
||||
// Keep selection independent from expansion so future keyboard actions (such as R to rename)
|
||||
// can target the current VCS file or folder without changing its open state.
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
|
||||
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedPath &&
|
||||
!allFolderPathSet.has(selectedPath) &&
|
||||
!files.some((file) => file.path === selectedPath)
|
||||
) {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [allFolderPathSet, files, selectedPath]);
|
||||
const effectiveCollapsedFolders = useMemo(
|
||||
() => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))),
|
||||
[allFolderPathSet, collapsedFolders],
|
||||
@@ -2181,6 +2353,40 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
[computeItemOffset, effectiveCollapsedFolders, mode],
|
||||
);
|
||||
|
||||
const handleCollapseFolder = useCallback(
|
||||
(dirPath: string) => {
|
||||
if (mode.kind !== "working_tree") {
|
||||
return;
|
||||
}
|
||||
const targetOffset = computeItemOffset(
|
||||
(item) => item.type === "folder" && item.dirPath === dirPath,
|
||||
);
|
||||
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||
if (
|
||||
targetOffset !== null &&
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: targetOffset,
|
||||
headerHeight: folderHeight,
|
||||
viewportOffset: diffListScrollOffsetRef.current,
|
||||
viewportHeight: diffListViewportHeightRef.current,
|
||||
})
|
||||
) {
|
||||
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
|
||||
}
|
||||
|
||||
const pathPrefix = `${dirPath}/`;
|
||||
mode.onCollapsedFoldersChange([
|
||||
...new Set([
|
||||
...effectiveCollapsedFolders,
|
||||
...allFolderPaths.filter(
|
||||
(folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
[allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode],
|
||||
);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
if (item.type === "folder") {
|
||||
@@ -2190,10 +2396,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
displayName={item.displayName}
|
||||
depth={item.depth}
|
||||
collapsed={item.collapsed}
|
||||
isSelected={selectedPath === item.dirPath}
|
||||
additions={item.additions}
|
||||
deletions={item.deletions}
|
||||
onToggle={handleToggleFolder}
|
||||
onCollapse={handleCollapseFolder}
|
||||
onSelect={handleSelectPath}
|
||||
onHeightChange={handleFolderRowHeightChange}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
onReveal={onReveal}
|
||||
revealTargetName={revealTargetName}
|
||||
onDuplicate={onDuplicate}
|
||||
onRevert={onRevert}
|
||||
testID={`diff-folder-${item.dirPath}`}
|
||||
/>
|
||||
);
|
||||
@@ -2204,14 +2419,21 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
file={item.file}
|
||||
workspaceFileDragScope={workspaceFileDragScope}
|
||||
isExpanded={item.isExpanded}
|
||||
isSelected={selectedPath === item.file.path}
|
||||
depth={item.depth}
|
||||
showDir={viewMode === "flat"}
|
||||
interactive={interactive}
|
||||
onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined}
|
||||
onSelect={handleSelectPath}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={onAddToChat}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
onReveal={onReveal}
|
||||
revealTargetName={revealTargetName}
|
||||
onDownload={onDownload}
|
||||
onDuplicate={onDuplicate}
|
||||
onRevert={onRevert}
|
||||
onHeaderHeightChange={handleHeaderHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}`}
|
||||
/>
|
||||
@@ -2235,6 +2457,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
handleBodyHeightChange,
|
||||
handleFolderRowHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleCollapseFolder,
|
||||
handleSelectPath,
|
||||
handleToggleExpanded,
|
||||
handleToggleFolder,
|
||||
layout,
|
||||
@@ -2248,7 +2472,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDownload,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
selectedPath,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2659,6 +2889,11 @@ export function GitDiffPane({
|
||||
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
|
||||
|
||||
const toast = useToast();
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const { targets: desktopOpenTargets } = useDesktopOpenTargets({
|
||||
isLocalExecution: isLocalDaemon,
|
||||
});
|
||||
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
|
||||
const {
|
||||
changesTabOpen,
|
||||
toggleChanges: handleToggleChangesTab,
|
||||
@@ -2668,6 +2903,11 @@ export function GitDiffPane({
|
||||
const refreshSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client);
|
||||
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||
const fsEntryDuplicateEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
||||
@@ -2773,12 +3013,51 @@ export function GitDiffPane({
|
||||
},
|
||||
[cwd],
|
||||
);
|
||||
const handleCopyRelativePath = useCallback((path: string) => {
|
||||
void Clipboard.setStringAsync(path);
|
||||
}, []);
|
||||
const handleRevealPath = useCallback(
|
||||
async (path: string) => {
|
||||
if (!fileManagerTarget) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openDesktopTarget({
|
||||
editorId: fileManagerTarget.id,
|
||||
workspacePath: cwd,
|
||||
filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
|
||||
});
|
||||
} catch (cause) {
|
||||
toast.error(
|
||||
cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"),
|
||||
);
|
||||
}
|
||||
},
|
||||
[cwd, fileManagerTarget, t, toast],
|
||||
);
|
||||
const handleDownloadPath = useCallback(
|
||||
(path: string) => {
|
||||
downloadFile({ fileName: path.split("/").pop() ?? path, path });
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
const handleDuplicatePath = useCallback(
|
||||
async (path: string) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await client.duplicateFileEntry({ cwd, path });
|
||||
if (!payload.success) {
|
||||
toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed"));
|
||||
}
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
},
|
||||
[client, cwd, t, toast],
|
||||
);
|
||||
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
|
||||
const workingTreeMode = useMemo(
|
||||
() => ({
|
||||
kind: "working_tree" as const,
|
||||
@@ -2791,7 +3070,12 @@ export function GitDiffPane({
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath: handleCopyPath,
|
||||
onCopyRelativePath: handleCopyRelativePath,
|
||||
onReveal: fileManagerTarget ? handleRevealPath : undefined,
|
||||
revealTargetName: fileManagerTarget?.label,
|
||||
onDownload: handleDownloadPath,
|
||||
onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined,
|
||||
onRevert: onRevertPath,
|
||||
onExpandedPathsChange: changesTree.updateExpandedPaths,
|
||||
onCollapsedFoldersChange: changesTree.updateCollapsedFolders,
|
||||
}),
|
||||
@@ -2806,7 +3090,13 @@ export function GitDiffPane({
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
handleCopyPath,
|
||||
handleCopyRelativePath,
|
||||
handleDownloadPath,
|
||||
handleDuplicatePath,
|
||||
handleRevealPath,
|
||||
fileManagerTarget,
|
||||
fsEntryDuplicateEnabled,
|
||||
onRevertPath,
|
||||
changesTree.updateExpandedPaths,
|
||||
changesTree.updateCollapsedFolders,
|
||||
],
|
||||
@@ -2861,7 +3151,12 @@ export function GitDiffPane({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
{...{
|
||||
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||
}}
|
||||
style={styles.container}
|
||||
>
|
||||
{isGit && (currentBranchName || isMobile) ? (
|
||||
<View style={styles.header} testID="changes-header">
|
||||
<BranchSwitcher
|
||||
@@ -3170,6 +3465,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
fileChevronSpacer: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
flexShrink: 0,
|
||||
},
|
||||
fileIcon: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
@@ -3183,6 +3483,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
fileDir: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -3190,6 +3491,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
fileDirSpacer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
return {
|
||||
@@ -252,6 +253,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const createEntry = useCallback(
|
||||
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
const payload = await client.createFileEntry({
|
||||
cwd: normalizedWorkspaceRoot,
|
||||
...input,
|
||||
});
|
||||
if (payload.success) {
|
||||
await requestDirectoryListing(input.parentPath, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||
);
|
||||
|
||||
const renameEntry = useCallback(
|
||||
async (input: { path: string; name: string }) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
const payload = await client.renameFileEntry({
|
||||
cwd: normalizedWorkspaceRoot,
|
||||
...input,
|
||||
});
|
||||
if (payload.success) {
|
||||
await requestDirectoryListing(parentExplorerPath(input.path), {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||
);
|
||||
|
||||
const duplicateEntry = useCallback(
|
||||
async (path: string) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
const payload = await client.duplicateFileEntry({ cwd: normalizedWorkspaceRoot, path });
|
||||
if (payload.success) {
|
||||
await requestDirectoryListing(parentExplorerPath(path), {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||
);
|
||||
|
||||
const deleteEntry = useCallback(
|
||||
async (path: string) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
const payload = await client.deleteFileEntry({ cwd: normalizedWorkspaceRoot, path });
|
||||
if (payload.success) {
|
||||
await requestDirectoryListing(parentExplorerPath(path), {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||
);
|
||||
|
||||
const selectExplorerEntry = useCallback(
|
||||
(path: string | null) => {
|
||||
updateExplorerState((state) => ({
|
||||
@@ -267,6 +342,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
deleteEntry,
|
||||
selectExplorerEntry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,9 +393,32 @@ export const ar: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "افتح الملف",
|
||||
copyPath: "نسخ المسار",
|
||||
copyRelativePath: "نسخ المسار النسبي",
|
||||
revealIn: "إظهار في {{target}}",
|
||||
download: "تحميل",
|
||||
addToChat: "إضافة إلى الدردشة",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
newFile: "ملف جديد",
|
||||
newFolder: "مجلد جديد",
|
||||
collapseFolder: "طي المجلد",
|
||||
rename: "إعادة تسمية",
|
||||
duplicate: "تكرار",
|
||||
revert: "تجاهل التغييرات",
|
||||
delete: "حذف",
|
||||
confirmDelete: {
|
||||
fileTitle: "حذف الملف؟",
|
||||
folderTitle: "حذف المجلد؟",
|
||||
message: 'سيتم حذف "{{name}}" نهائيًا.',
|
||||
confirm: "حذف",
|
||||
cancel: "إلغاء",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "تجاهل التغييرات؟",
|
||||
message: 'سيتم تجاهل التغييرات في "{{name}}" نهائيًا.',
|
||||
confirm: "تجاهل",
|
||||
cancel: "إلغاء",
|
||||
failed: "فشل تجاهل التغييرات",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -425,6 +448,15 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "فشل في سرد الدليل",
|
||||
createFailed: "فشل إنشاء العنصر",
|
||||
renameFailed: "فشل إعادة تسمية العنصر",
|
||||
duplicateFailed: "فشل تكرار العنصر",
|
||||
revealFailed: "فشل إظهار العنصر",
|
||||
deleteFailed: "فشل حذف العنصر",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "اسم الملف",
|
||||
folderPlaceholder: "اسم المجلد",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -392,9 +392,32 @@ export const en = {
|
||||
fileActions: {
|
||||
openFile: "Open file",
|
||||
copyPath: "Copy path",
|
||||
copyRelativePath: "Copy relative path",
|
||||
revealIn: "Reveal in {{target}}",
|
||||
download: "Download",
|
||||
addToChat: "Add to chat",
|
||||
moreActions: "More actions",
|
||||
newFile: "New file",
|
||||
newFolder: "New folder",
|
||||
collapseFolder: "Collapse folder",
|
||||
rename: "Rename",
|
||||
duplicate: "Duplicate",
|
||||
revert: "Discard changes",
|
||||
delete: "Delete",
|
||||
confirmDelete: {
|
||||
fileTitle: "Delete file?",
|
||||
folderTitle: "Delete folder?",
|
||||
message: '"{{name}}" will be permanently deleted.',
|
||||
confirm: "Delete",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "Discard changes?",
|
||||
message: 'Changes to "{{name}}" will be permanently discarded.',
|
||||
confirm: "Discard",
|
||||
cancel: "Cancel",
|
||||
failed: "Failed to discard changes",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -424,6 +447,15 @@ export const en = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "Failed to list directory",
|
||||
createFailed: "Failed to create entry",
|
||||
renameFailed: "Failed to rename entry",
|
||||
duplicateFailed: "Failed to duplicate entry",
|
||||
revealFailed: "Failed to reveal entry",
|
||||
deleteFailed: "Failed to delete entry",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "File name",
|
||||
folderPlaceholder: "Folder name",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -397,9 +397,32 @@ export const es: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "Abrir archivo",
|
||||
copyPath: "Copiar ruta",
|
||||
copyRelativePath: "Copiar ruta relativa",
|
||||
revealIn: "Mostrar en {{target}}",
|
||||
download: "Descargar",
|
||||
addToChat: "Añadir al chat",
|
||||
moreActions: "Más acciones",
|
||||
newFile: "Nuevo archivo",
|
||||
newFolder: "Nueva carpeta",
|
||||
collapseFolder: "Contraer carpeta",
|
||||
rename: "Renombrar",
|
||||
duplicate: "Duplicar",
|
||||
revert: "Descartar cambios",
|
||||
delete: "Eliminar",
|
||||
confirmDelete: {
|
||||
fileTitle: "¿Eliminar archivo?",
|
||||
folderTitle: "¿Eliminar carpeta?",
|
||||
message: '"{{name}}" se eliminará permanentemente.',
|
||||
confirm: "Eliminar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "¿Descartar cambios?",
|
||||
message: 'Los cambios en "{{name}}" se descartarán permanentemente.',
|
||||
confirm: "Descartar",
|
||||
cancel: "Cancelar",
|
||||
failed: "No se pudieron descartar los cambios",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -429,6 +452,15 @@ export const es: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "No se pudo listar el directorio",
|
||||
createFailed: "No se pudo crear la entrada",
|
||||
renameFailed: "No se pudo renombrar la entrada",
|
||||
duplicateFailed: "No se pudo duplicar la entrada",
|
||||
revealFailed: "No se pudo mostrar la entrada",
|
||||
deleteFailed: "No se pudo eliminar la entrada",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "Nombre del archivo",
|
||||
folderPlaceholder: "Nombre de la carpeta",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -397,9 +397,32 @@ export const fr: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "Ouvrir le fichier",
|
||||
copyPath: "Copier le chemin",
|
||||
copyRelativePath: "Copier le chemin relatif",
|
||||
revealIn: "Afficher dans {{target}}",
|
||||
download: "Télécharger",
|
||||
addToChat: "Ajouter au chat",
|
||||
moreActions: "Plus de propositions",
|
||||
newFile: "Nouveau fichier",
|
||||
newFolder: "Nouveau dossier",
|
||||
collapseFolder: "Replier le dossier",
|
||||
rename: "Renommer",
|
||||
duplicate: "Dupliquer",
|
||||
revert: "Abandonner les modifications",
|
||||
delete: "Supprimer",
|
||||
confirmDelete: {
|
||||
fileTitle: "Supprimer le fichier ?",
|
||||
folderTitle: "Supprimer le dossier ?",
|
||||
message: "« {{name}} » sera définitivement supprimé.",
|
||||
confirm: "Supprimer",
|
||||
cancel: "Annuler",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "Abandonner les modifications ?",
|
||||
message: "Les modifications de « {{name}} » seront définitivement abandonnées.",
|
||||
confirm: "Abandonner",
|
||||
cancel: "Annuler",
|
||||
failed: "Échec de l'abandon des modifications",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -429,6 +452,15 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "Échec de la liste du répertoire",
|
||||
createFailed: "Échec de la création de l'entrée",
|
||||
renameFailed: "Échec du renommage de l'entrée",
|
||||
duplicateFailed: "Échec de la duplication de l'entrée",
|
||||
revealFailed: "Échec de l'affichage de l'entrée",
|
||||
deleteFailed: "Échec de la suppression de l'entrée",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "Nom du fichier",
|
||||
folderPlaceholder: "Nom du dossier",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -397,9 +397,32 @@ export const ja: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "ファイルを開く",
|
||||
copyPath: "パスをコピー",
|
||||
copyRelativePath: "相対パスをコピー",
|
||||
revealIn: "{{target}}で表示",
|
||||
download: "ダウンロード",
|
||||
addToChat: "チャットに追加",
|
||||
moreActions: "その他のアクション",
|
||||
newFile: "新規ファイル",
|
||||
newFolder: "新規フォルダ",
|
||||
collapseFolder: "フォルダを折りたたむ",
|
||||
rename: "名前を変更",
|
||||
duplicate: "複製",
|
||||
revert: "変更を破棄",
|
||||
delete: "削除",
|
||||
confirmDelete: {
|
||||
fileTitle: "ファイルを削除しますか?",
|
||||
folderTitle: "フォルダを削除しますか?",
|
||||
message: "「{{name}}」は完全に削除されます。",
|
||||
confirm: "削除",
|
||||
cancel: "キャンセル",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "変更を破棄しますか?",
|
||||
message: "「{{name}}」への変更は完全に破棄されます。",
|
||||
confirm: "破棄",
|
||||
cancel: "キャンセル",
|
||||
failed: "変更の破棄に失敗しました",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -429,6 +452,15 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "ディレクトリの一覧取得に失敗しました",
|
||||
createFailed: "エントリの作成に失敗しました",
|
||||
renameFailed: "エントリの名前変更に失敗しました",
|
||||
duplicateFailed: "エントリの複製に失敗しました",
|
||||
revealFailed: "エントリの表示に失敗しました",
|
||||
deleteFailed: "エントリの削除に失敗しました",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "ファイル名",
|
||||
folderPlaceholder: "フォルダ名",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -394,9 +394,32 @@ export const ko: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "파일 열기",
|
||||
copyPath: "경로 복사",
|
||||
copyRelativePath: "상대 경로 복사",
|
||||
revealIn: "{{target}}에서 보기",
|
||||
download: "다운로드",
|
||||
addToChat: "채팅에 추가",
|
||||
moreActions: "추가 작업",
|
||||
newFile: "새 파일",
|
||||
newFolder: "새 폴더",
|
||||
collapseFolder: "폴더 접기",
|
||||
rename: "이름 바꾸기",
|
||||
duplicate: "복제",
|
||||
revert: "변경 사항 버리기",
|
||||
delete: "삭제",
|
||||
confirmDelete: {
|
||||
fileTitle: "파일을 삭제할까요?",
|
||||
folderTitle: "폴더를 삭제할까요?",
|
||||
message: '"{{name}}"이(가) 영구적으로 삭제됩니다.',
|
||||
confirm: "삭제",
|
||||
cancel: "취소",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "변경 사항을 버릴까요?",
|
||||
message: '"{{name}}"의 변경 사항이 영구적으로 삭제됩니다.',
|
||||
confirm: "버리기",
|
||||
cancel: "취소",
|
||||
failed: "변경 사항을 버리지 못했습니다",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -426,6 +449,15 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "디렉터리 목록을 불러오지 못했습니다",
|
||||
createFailed: "항목을 만들지 못했습니다",
|
||||
renameFailed: "항목 이름을 바꾸지 못했습니다",
|
||||
duplicateFailed: "항목을 복제하지 못했습니다",
|
||||
revealFailed: "항목을 표시하지 못했습니다",
|
||||
deleteFailed: "항목을 삭제하지 못했습니다",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "파일 이름",
|
||||
folderPlaceholder: "폴더 이름",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -397,9 +397,32 @@ export const ptBR: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "Abrir arquivo",
|
||||
copyPath: "Copiar caminho",
|
||||
copyRelativePath: "Copiar caminho relativo",
|
||||
revealIn: "Mostrar no {{target}}",
|
||||
download: "Baixar",
|
||||
addToChat: "Adicionar ao chat",
|
||||
moreActions: "Mais ações",
|
||||
newFile: "Novo arquivo",
|
||||
newFolder: "Nova pasta",
|
||||
collapseFolder: "Recolher pasta",
|
||||
rename: "Renomear",
|
||||
duplicate: "Duplicar",
|
||||
revert: "Descartar alterações",
|
||||
delete: "Excluir",
|
||||
confirmDelete: {
|
||||
fileTitle: "Excluir arquivo?",
|
||||
folderTitle: "Excluir pasta?",
|
||||
message: '"{{name}}" será excluído permanentemente.',
|
||||
confirm: "Excluir",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "Descartar alterações?",
|
||||
message: 'As alterações em "{{name}}" serão descartadas permanentemente.',
|
||||
confirm: "Descartar",
|
||||
cancel: "Cancelar",
|
||||
failed: "Falha ao descartar alterações",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -429,6 +452,15 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "Falha ao listar diretório",
|
||||
createFailed: "Falha ao criar entrada",
|
||||
renameFailed: "Falha ao renomear entrada",
|
||||
duplicateFailed: "Falha ao duplicar entrada",
|
||||
revealFailed: "Falha ao mostrar entrada",
|
||||
deleteFailed: "Falha ao excluir entrada",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "Nome do arquivo",
|
||||
folderPlaceholder: "Nome da pasta",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -396,9 +396,32 @@ export const ru: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "Открыть файл",
|
||||
copyPath: "Копировать путь",
|
||||
copyRelativePath: "Копировать относительный путь",
|
||||
revealIn: "Показать в {{target}}",
|
||||
download: "Скачать",
|
||||
addToChat: "Добавить в чат",
|
||||
moreActions: "Дополнительные действия",
|
||||
newFile: "Новый файл",
|
||||
newFolder: "Новая папка",
|
||||
collapseFolder: "Свернуть папку",
|
||||
rename: "Переименовать",
|
||||
duplicate: "Дублировать",
|
||||
revert: "Отменить изменения",
|
||||
delete: "Удалить",
|
||||
confirmDelete: {
|
||||
fileTitle: "Удалить файл?",
|
||||
folderTitle: "Удалить папку?",
|
||||
message: "«{{name}}» будет удалён безвозвратно.",
|
||||
confirm: "Удалить",
|
||||
cancel: "Отмена",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "Отменить изменения?",
|
||||
message: "Изменения в «{{name}}» будут безвозвратно отменены.",
|
||||
confirm: "Отменить",
|
||||
cancel: "Отмена",
|
||||
failed: "Не удалось отменить изменения",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -428,6 +451,15 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "Не удалось указать каталог",
|
||||
createFailed: "Не удалось создать элемент",
|
||||
renameFailed: "Не удалось переименовать элемент",
|
||||
duplicateFailed: "Не удалось дублировать элемент",
|
||||
revealFailed: "Не удалось показать элемент",
|
||||
deleteFailed: "Не удалось удалить элемент",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "Имя файла",
|
||||
folderPlaceholder: "Имя папки",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -393,9 +393,32 @@ export const zhCN: TranslationResources = {
|
||||
fileActions: {
|
||||
openFile: "打开文件",
|
||||
copyPath: "复制路径",
|
||||
copyRelativePath: "复制相对路径",
|
||||
revealIn: "在 {{target}} 中显示",
|
||||
download: "下载",
|
||||
addToChat: "添加到聊天",
|
||||
moreActions: "更多操作",
|
||||
newFile: "新建文件",
|
||||
newFolder: "新建文件夹",
|
||||
collapseFolder: "折叠文件夹",
|
||||
rename: "重命名",
|
||||
duplicate: "复制",
|
||||
revert: "放弃更改",
|
||||
delete: "删除",
|
||||
confirmDelete: {
|
||||
fileTitle: "删除文件?",
|
||||
folderTitle: "删除文件夹?",
|
||||
message: "“{{name}}”将被永久删除。",
|
||||
confirm: "删除",
|
||||
cancel: "取消",
|
||||
},
|
||||
confirmRevert: {
|
||||
title: "放弃更改?",
|
||||
message: "对“{{name}}”的更改将被永久放弃。",
|
||||
confirm: "放弃",
|
||||
cancel: "取消",
|
||||
failed: "放弃更改失败",
|
||||
},
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
@@ -425,6 +448,15 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
errors: {
|
||||
failedToListDirectory: "列出目录失败",
|
||||
createFailed: "创建条目失败",
|
||||
renameFailed: "重命名条目失败",
|
||||
duplicateFailed: "复制条目失败",
|
||||
revealFailed: "显示条目失败",
|
||||
deleteFailed: "删除条目失败",
|
||||
},
|
||||
draft: {
|
||||
filePlaceholder: "文件名",
|
||||
folderPlaceholder: "文件夹名称",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -32,3 +32,8 @@ export function buildAbsoluteExplorerPath({
|
||||
|
||||
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
|
||||
}
|
||||
|
||||
export function parentExplorerPath(entryPath: string): string {
|
||||
const separatorIndex = entryPath.lastIndexOf("/");
|
||||
return separatorIndex > 0 ? entryPath.slice(0, separatorIndex) : ".";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user