Files
paseo/packages/app/src/panels/commit-diff-panel.tsx
T
Mohamed BoudraandGitHub 4bda2dfea9 Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
2026-07-21 04:41:19 +08:00

185 lines
5.7 KiB
TypeScript

import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { SharedDiffView } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const { settings } = useAppSettings();
const { preferences, updatePreferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const canUseSplitLayout = isWeb && !isCompact;
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
const layoutOptions = useMemo(
() => [
{
value: "unified" as const,
label: t("workspace.git.diff.unified"),
testID: "commit-diff-layout-unified",
},
{
value: "split" as const,
label: t("workspace.git.diff.split"),
testID: "commit-diff-layout-split",
},
],
[t],
);
const handleLayoutChange = useCallback(
(layout: "unified" | "split") => {
void updatePreferences({ layout });
},
[updatePreferences],
);
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
if (!cwd) {
return (
<View style={styles.centerState}>
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
</View>
);
}
if (capabilityMissing) {
return (
<View style={styles.centerState} testID="commit-diff-capability-missing">
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
</View>
);
}
let bodyContent: ReactNode;
if (error) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-error">
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
</View>
);
} else if (isLoading && files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-loading">
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
</View>
);
} else if (files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-empty">
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
</View>
);
} else {
bodyContent = (
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
);
}
return (
<View style={styles.container} testID="commit-diff-panel">
{canUseSplitLayout ? (
<View style={styles.toolbar} testID="commit-diff-toolbar">
<SegmentedControl
options={layoutOptions}
value={preferences.layout}
onValueChange={handleLayoutChange}
size="sm"
testID="commit-diff-layout-control"
/>
</View>
) : null}
<View style={styles.body}>{bodyContent}</View>
</View>
);
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
body: {
flex: 1,
minHeight: 0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));