mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
fix(app): put content search in the real Command Center
The content scope replaced the whole panel with a lookalike shell: its own
query field pinned to the bottom, its own scope button, and a permanent strip
naming the implementation ("Saved files up to 1 MiB · Ignore case"). Search
now uses the Command Center's own header, so every scope shares one input,
one chip, one loading slot and one key dispatcher. Content search contributes
only a body: results left, source preview right, in a wider panel.
The strip is gone. Idle shows nothing, an empty result set reuses the shared
"No matches", and loading and errors are the existing spinner and Retry.
Two row defects fell out of the wider layout. A deep path truncated its own
file name away, so a row could not be identified; the name and coordinates now
lead and the directory is what truncation eats. A match far into a long line
sat outside the row entirely; the run-up is trimmed when there is enough of it
to be worth trimming. The preview centres such a match horizontally instead of
leaving it clipped at the scroller edge.
Pane Find required pane context, which an overlay preview does not have, so
rendering a source view inside the Command Center threw. Find now belongs to
panes only and stands down elsewhere, and the file pane routes through the
source module instead of rebuilding its theme.
This commit is contained in:
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
await page.getByTestId("command-center-scope").click();
|
||||
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
scope: '[data-testid="command-center-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -28,9 +28,10 @@ export async function previewAndOpenSecondOccurrence(page: Page, screenshot: str
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
await expect(input).toBeFocused();
|
||||
// The query field is the Command Center's own header input, above the results and preview.
|
||||
const inputBox = await input.boundingBox();
|
||||
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
|
||||
expect(inputBox!.y).toBeGreaterThan(previewBox!.y + previewBox!.height);
|
||||
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
|
||||
await panel.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
@@ -91,6 +91,16 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const SCOPE_LABELS = {
|
||||
files: {
|
||||
chip: "shell.commandCenter.files",
|
||||
placeholder: "shell.commandCenter.filePlaceholder",
|
||||
},
|
||||
content: {
|
||||
chip: "shell.commandCenter.content",
|
||||
placeholder: "shell.commandCenter.contentPlaceholder",
|
||||
},
|
||||
} as const;
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
@@ -703,22 +713,30 @@ export function CommandCenter() {
|
||||
onScroll: handleListScroll,
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
// Every scope wears the same header; only the chip and the placeholder name it.
|
||||
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
|
||||
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
|
||||
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
|
||||
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
|
||||
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
|
||||
// can never disagree about who is steering.
|
||||
const dispatchKey = useCallback(
|
||||
(key: string) =>
|
||||
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
|
||||
[dispatchKey],
|
||||
);
|
||||
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handled =
|
||||
state.scope === "content"
|
||||
? (contentKeyHandler.current?.(event.key) ?? false)
|
||||
: state.key(event.key);
|
||||
if (!handled) return false;
|
||||
if (!dispatchKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
[dispatchKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
@@ -752,46 +770,37 @@ export function CommandCenter() {
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
bottomSheet
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[styles.bottomSheetHeader, styles.searchRow]}
|
||||
testID="command-center-header"
|
||||
>
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</>
|
||||
@@ -810,40 +819,36 @@ export function CommandCenter() {
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
|
||||
>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{scopeChipLabel ? (
|
||||
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
bottomSheet={false}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</>
|
||||
@@ -881,7 +886,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
testID="command-center-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInputKeyPressEventData,
|
||||
} from "react-native";
|
||||
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditingTextInput } from "@/components/ui/text-input";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
@@ -22,21 +15,26 @@ import { createWorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { WorkspaceFileSource } from "@/file-pane/source";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./internal/model";
|
||||
import { describeWorkspaceFilePath } from "../workspace-file-search-model";
|
||||
import {
|
||||
WorkspaceContentSearchModel,
|
||||
type ContentSearchSnapshot,
|
||||
type ContentSearchTransport,
|
||||
} from "./internal/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import type { ContentSearchSnapshot } from "./internal/model";
|
||||
interface Props {
|
||||
/** The Command Center owns the field; this body only reacts to what was typed in it. */
|
||||
query: string;
|
||||
compact: boolean;
|
||||
bottomSheet: boolean;
|
||||
close(): void;
|
||||
clearScope(): void;
|
||||
keyHandler: React.RefObject<((key: string) => boolean) | null>;
|
||||
}
|
||||
|
||||
export function WorkspaceContentSearch(props: Props) {
|
||||
const { keyHandler, close, clearScope } = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -78,13 +76,14 @@ export function WorkspaceContentSearch(props: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSearch({
|
||||
transport,
|
||||
cwd,
|
||||
serverId,
|
||||
workspaceId,
|
||||
query,
|
||||
compact,
|
||||
bottomSheet,
|
||||
close,
|
||||
clearScope,
|
||||
keyHandler,
|
||||
@@ -94,7 +93,6 @@ function ContentSearch({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [previewPage, setPreviewPage] = useState(false);
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
@@ -121,6 +119,12 @@ function ContentSearch({
|
||||
[cwd, transport, serverId, workspaceId],
|
||||
);
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
// The field lives in the Command Center header, so the typed value is a prop; this is the only
|
||||
// bridge from it into the model that owns the search.
|
||||
useEffect(() => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(query);
|
||||
}, [model, query]);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
useEffect(() => {
|
||||
@@ -159,19 +163,6 @@ function ContentSearch({
|
||||
if (keyHandler.current === key) keyHandler.current = null;
|
||||
};
|
||||
}, [keyHandler, key]);
|
||||
const onKey = useCallback(
|
||||
({ nativeEvent }: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (bottomSheet) key(nativeEvent.key);
|
||||
},
|
||||
[bottomSheet, key],
|
||||
);
|
||||
const changeQuery = useCallback(
|
||||
(value: string) => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(value);
|
||||
},
|
||||
[model],
|
||||
);
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
model.select(index);
|
||||
@@ -186,54 +177,34 @@ function ContentSearch({
|
||||
),
|
||||
[select, state.activeIndex],
|
||||
);
|
||||
const empty = useMemo(() => {
|
||||
let text = "";
|
||||
if (state.status === "idle") text = t("shell.commandCenter.contentEmpty");
|
||||
if (state.status === "ready") text = t("shell.commandCenter.noMatches");
|
||||
return <Text style={styles.status}>{text}</Text>;
|
||||
}, [state.status, t]);
|
||||
const emptyState = useMemo(
|
||||
() => <SearchState state={state} retry={model.retry} />,
|
||||
[model.retry, state],
|
||||
);
|
||||
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.body}>
|
||||
{!compact || !previewPage ? (
|
||||
<View style={[styles.results, compact && styles.full]}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={state.matches}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyExtractor={resultKey}
|
||||
getItemLayout={resultLayout}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={empty}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
<SearchStatus state={state} retry={model.retry} />
|
||||
<View style={styles.query}>
|
||||
<Button variant="ghost" size="sm" onPress={clearScope}>
|
||||
{t("shell.commandCenter.contentTitle")}
|
||||
</Button>
|
||||
<EditingTextInput
|
||||
initialValue={state.query}
|
||||
onChangeText={changeQuery}
|
||||
variant={bottomSheet ? "bottom-sheet" : "default"}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel={t("shell.commandCenter.contentPlaceholder")}
|
||||
placeholder={t("shell.commandCenter.contentPlaceholder")}
|
||||
onKeyPress={onKey}
|
||||
onSubmitEditing={model.openSelected}
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
{!compact || !previewPage ? (
|
||||
<View style={[styles.results, compact && styles.full]}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={state.matches}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyExtractor={resultKey}
|
||||
getItemLayout={resultLayout}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={emptyState}
|
||||
ListFooterComponent={limitNotice}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
item,
|
||||
index,
|
||||
@@ -247,6 +218,18 @@ function ResultRow({
|
||||
}) {
|
||||
const onPress = useCallback(() => select(index), [index, select]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
|
||||
// keyboard cursor that drives the preview stays distinguishable from the pointer.
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.hovered,
|
||||
selected && styles.selected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
const line = useMemo(() => describeMatchLine(item), [item]);
|
||||
const file = useMemo(() => describeWorkspaceFilePath(item.path), [item.path]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@@ -254,27 +237,72 @@ function ResultRow({
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? selected : undefined}
|
||||
onPress={onPress}
|
||||
style={[styles.row, selected && styles.selected]}
|
||||
style={style}
|
||||
>
|
||||
<Text style={styles.path} numberOfLines={1}>
|
||||
{item.path}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
<Text style={styles.snippet} numberOfLines={1}>
|
||||
{item.snippet.slice(0, item.snippetMatchStart)}
|
||||
<Text style={styles.match}>
|
||||
{item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd)}
|
||||
{line.before}
|
||||
<Text style={styles.match}>{line.match}</Text>
|
||||
{line.after}
|
||||
</Text>
|
||||
<Text style={styles.location} numberOfLines={1}>
|
||||
<Text style={styles.locationFile}>
|
||||
{file.name}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
{item.snippet.slice(item.snippetMatchEnd)}
|
||||
{file.directory ? <Text style={styles.locationDirectory}> {file.directory}</Text> : null}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
|
||||
function describeMatchLine(item: WorkspaceContentMatch) {
|
||||
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
|
||||
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
|
||||
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
|
||||
const before = item.snippet.slice(from, item.snippetMatchStart);
|
||||
return {
|
||||
before: cut ? `…${before}` : before.trimStart(),
|
||||
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
|
||||
after: item.snippet.slice(item.snippetMatchEnd),
|
||||
};
|
||||
}
|
||||
function resultKey(item: WorkspaceContentMatch) {
|
||||
return `${item.path}:${item.line}:${item.columnStart}`;
|
||||
}
|
||||
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
|
||||
return { length: 56, offset: index * 56, index };
|
||||
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
|
||||
}
|
||||
|
||||
/** Idle stays blank: the header placeholder already says what the field does. */
|
||||
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "searching")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "ready")
|
||||
return <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResultLimit({ limited }: { limited: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!limited) return null;
|
||||
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
|
||||
}
|
||||
|
||||
function ContentPreview({
|
||||
state,
|
||||
model,
|
||||
@@ -304,25 +332,33 @@ function ContentPreview({
|
||||
);
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
<View style={styles.previewHeader}>
|
||||
{compact ? (
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
{match ? (
|
||||
<View style={styles.previewHeader}>
|
||||
{compact ? (
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
|
||||
{match.path}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Text style={styles.previewPath} numberOfLines={1}>
|
||||
{match?.path ?? t("shell.commandCenter.contentPreview")}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" disabled={!match} onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "loading" ? (
|
||||
<Text style={styles.status}>{t("shell.commandCenter.contentLoadingPreview")}</Text>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
|
||||
>
|
||||
<ThemedSpinner size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "error" ? (
|
||||
<View style={styles.status}>
|
||||
<Text style={styles.error}>{preview.message}</Text>
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.retryPreview}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
@@ -340,102 +376,76 @@ function ContentPreview({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
function SearchStatus({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.statusRow} accessibilityLiveRegion="polite">
|
||||
{state.status === "searching" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.hint}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{state.status === "error" ? (
|
||||
<>
|
||||
<Text style={[styles.hint, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{state.limited ? (
|
||||
<Text style={styles.hint}>{t("shell.commandCenter.contentLimited")}</Text>
|
||||
) : null}
|
||||
{state.status === "idle" || (state.status === "ready" && !state.limited) ? (
|
||||
<Text
|
||||
style={styles.hint}
|
||||
accessibilityHint={t("shell.commandCenter.contentScopeDetail")}
|
||||
aria-description={isWeb ? t("shell.commandCenter.contentScopeDetail") : undefined}
|
||||
>
|
||||
{t("shell.commandCenter.contentScope")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Matches the Command Center's own two-line result row.
|
||||
const RESULT_ROW_HEIGHT = 56;
|
||||
// Characters of the matched line kept ahead of the match itself.
|
||||
const SNIPPET_LEAD = 24;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
body: { flex: 1, minHeight: 0, flexDirection: "row" },
|
||||
// A result row is two truncated single lines, so extra panel width buys the list nothing and
|
||||
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
|
||||
// desktop panel cannot starve the preview.
|
||||
results: {
|
||||
width: "40%",
|
||||
width: 360,
|
||||
maxWidth: "45%",
|
||||
minHeight: 0,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
full: { width: "100%", borderRightWidth: 0 },
|
||||
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
|
||||
row: {
|
||||
height: 56,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
height: RESULT_ROW_HEIGHT,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
hovered: { backgroundColor: theme.colors.surface1 },
|
||||
selected: { backgroundColor: theme.colors.surface2 },
|
||||
path: { color: theme.colors.foreground, fontSize: theme.fontSize.sm },
|
||||
// The matched line leads, in the code type scale; the location is its subtitle.
|
||||
snippet: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 18,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
match: {
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.terminal.selectionBackground,
|
||||
},
|
||||
match: { backgroundColor: theme.colors.terminal.selectionBackground },
|
||||
// The file name and coordinates lead so that truncation eats the directory, not the identity.
|
||||
location: { fontSize: theme.fontSize.sm, lineHeight: 16 },
|
||||
locationFile: { color: theme.colors.foreground },
|
||||
locationDirectory: { color: theme.colors.foregroundMuted },
|
||||
preview: { flex: 1, minWidth: 0, minHeight: 0 },
|
||||
previewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
status: {
|
||||
padding: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
limit: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusRow: {
|
||||
minHeight: 40,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hint: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
query: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
...(isWeb ? { outlineWidth: 0 } : {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -201,3 +201,34 @@ test("textual image occurrences expose saved source without changing the read ki
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
|
||||
const reads: string[] = [];
|
||||
let deleted = false;
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
|
||||
readFile: async (_cwd, path) => {
|
||||
reads.push(path);
|
||||
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
|
||||
return file(path, `NEEDLE in ${path}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(reads).toEqual(["a.ts"]);
|
||||
model.move(1);
|
||||
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
|
||||
deleted = true;
|
||||
model.move(-1);
|
||||
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
|
||||
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ContentSearchSnapshot {
|
||||
preview: Preview;
|
||||
}
|
||||
|
||||
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
|
||||
const MAX_PREVIEW_BYTES = 1_048_576;
|
||||
export class WorkspaceContentSearchModel {
|
||||
private snapshot: ContentSearchSnapshot = {
|
||||
query: "",
|
||||
@@ -122,13 +124,11 @@ export class WorkspaceContentSearchModel {
|
||||
private async preview() {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match) return;
|
||||
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
|
||||
// tell the truth about a file that changed or disappeared since the search ran; see the
|
||||
// deleted-file recovery case in the browser spec before trading it for fewer reads.
|
||||
if (this.file?.path === match.path) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "ready",
|
||||
...this.file,
|
||||
},
|
||||
});
|
||||
this.update({ preview: { status: "ready", ...this.file } });
|
||||
return;
|
||||
}
|
||||
this.update({ preview: { status: "loading" } });
|
||||
@@ -141,17 +141,13 @@ export class WorkspaceContentSearchModel {
|
||||
this.options.cwd,
|
||||
path,
|
||||
undefined,
|
||||
1_048_576,
|
||||
MAX_PREVIEW_BYTES,
|
||||
);
|
||||
if (this.disposed) return;
|
||||
if (query === this.controller) {
|
||||
const content = explorerFileFromReadResult(file).content;
|
||||
if (content === undefined) throw new Error("This file is no longer text");
|
||||
this.file = {
|
||||
path,
|
||||
content,
|
||||
size: file.size,
|
||||
};
|
||||
this.file = { path, content, size: file.size };
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { usePaneFocusIfAny } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
@@ -29,7 +29,9 @@ export function FileFind({
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
const { isInteractive } = usePaneFocus();
|
||||
// A source view can also render outside any pane, such as the Command Center content
|
||||
// preview. Pane Find belongs to the pane, so there is nothing to open there.
|
||||
const pane = usePaneFocusIfAny();
|
||||
const active = useRetainedPanelActive();
|
||||
// RN Web hands the underlying DOM element to a View ref; the model measures it.
|
||||
const setWidgetNode = useCallback(
|
||||
@@ -37,7 +39,7 @@ export function FileFind({
|
||||
[model],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isInteractive || !active) return;
|
||||
if (!pane?.isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
@@ -54,7 +56,7 @@ export function FileFind({
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [active, editor, isInteractive, model]);
|
||||
}, [active, editor, model, pane?.isInteractive]);
|
||||
|
||||
const replace = useMemo(
|
||||
() =>
|
||||
@@ -68,7 +70,7 @@ export function FileFind({
|
||||
},
|
||||
[model, state.readOnly, state.replacement],
|
||||
);
|
||||
if (!state.open) return null;
|
||||
if (!pane || !state.open) return null;
|
||||
const total = `${state.total}${state.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (state.query) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { FileSourceView } from "./source/view";
|
||||
import { WorkspaceFileSource } from "./source";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -79,32 +79,13 @@ function ReadonlySource({
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
<WorkspaceFileSource
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,8 @@ function ReadonlyCodeMirror({
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
// A match far into a long line lands at the scroller edge without the horizontal centre.
|
||||
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
|
||||
});
|
||||
}, [content, location, navigationRevision]);
|
||||
|
||||
|
||||
@@ -74,18 +74,14 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "البحث في محتوى الملفات",
|
||||
content: "محتوى الملفات",
|
||||
contentPlaceholder: "البحث في الملفات المحفوظة...",
|
||||
contentUnavailable: "مساحة العمل غير متاحة",
|
||||
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
|
||||
contentEmpty: "البحث في الملفات المحفوظة",
|
||||
contentBack: "العودة إلى النتائج",
|
||||
contentPreview: "معاينة",
|
||||
contentOpen: "فتح",
|
||||
contentLoadingPreview: "جارٍ تحميل المعاينة...",
|
||||
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
|
||||
contentScopeDetail:
|
||||
"يبحث في ملفات UTF-8 المحفوظة، بما فيها المخفية وغير المتتبعة. تُطبق قواعد التجاهل. لا يشمل الروابط الرمزية والتعديلات غير المحفوظة.",
|
||||
contentScope: "ملفات محفوظة حتى 1 MiB · تجاهل حالة الأحرف",
|
||||
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
|
||||
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
|
||||
@@ -71,18 +71,14 @@ export const en = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Search file contents",
|
||||
content: "File contents",
|
||||
contentPlaceholder: "Search saved file contents...",
|
||||
contentUnavailable: "Workspace unavailable",
|
||||
contentUpdateHost: "Update this host to search file contents",
|
||||
contentEmpty: "Search saved file contents",
|
||||
contentBack: "Back to results",
|
||||
contentPreview: "Preview",
|
||||
contentOpen: "Open",
|
||||
contentLoadingPreview: "Loading preview...",
|
||||
contentLimited: "Results limited — refine your search",
|
||||
contentScopeDetail:
|
||||
"Searches saved UTF-8 files, including hidden and untracked files. Ignore rules apply. Symbolic links and unsaved edits are excluded.",
|
||||
contentScope: "Saved files up to 1 MiB · Ignore case",
|
||||
contentChanged: "File changed — saved match is no longer at this location",
|
||||
|
||||
placeholder: "Search commands, files, workspaces, and agents...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const es: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar contenido de archivos",
|
||||
content: "Contenido de archivos",
|
||||
contentPlaceholder: "Buscar contenido guardado...",
|
||||
contentUnavailable: "Espacio de trabajo no disponible",
|
||||
contentUpdateHost: "Actualiza este host para buscar contenido",
|
||||
contentEmpty: "Buscar contenido guardado",
|
||||
contentBack: "Volver a resultados",
|
||||
contentPreview: "Vista previa",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Cargando vista previa...",
|
||||
contentLimited: "Resultados limitados — precisa la búsqueda",
|
||||
contentScopeDetail:
|
||||
"Busca archivos UTF-8 guardados, incluidos ocultos y sin seguimiento. Respeta las reglas de exclusión. Excluye enlaces simbólicos y cambios sin guardar.",
|
||||
contentScope: "Archivos guardados hasta 1 MiB · Sin distinguir mayúsculas",
|
||||
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
|
||||
|
||||
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
|
||||
|
||||
@@ -75,18 +75,14 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Rechercher dans les fichiers",
|
||||
content: "Contenu des fichiers",
|
||||
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
|
||||
contentUnavailable: "Espace de travail indisponible",
|
||||
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
|
||||
contentEmpty: "Rechercher dans les fichiers enregistrés",
|
||||
contentBack: "Retour aux résultats",
|
||||
contentPreview: "Aperçu",
|
||||
contentOpen: "Ouvrir",
|
||||
contentLoadingPreview: "Chargement de l’aperçu...",
|
||||
contentLimited: "Résultats limités — précisez la recherche",
|
||||
contentScopeDetail:
|
||||
"Recherche dans les fichiers UTF-8 enregistrés, y compris cachés et non suivis. Respecte les règles d’exclusion. Exclut les liens symboliques et les modifications non enregistrées.",
|
||||
contentScope: "Fichiers enregistrés jusqu’à 1 Mio · Casse ignorée",
|
||||
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
|
||||
|
||||
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "ファイル内容を検索",
|
||||
content: "ファイル内容",
|
||||
contentPlaceholder: "保存済みファイルの内容を検索...",
|
||||
contentUnavailable: "ワークスペースを利用できません",
|
||||
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
|
||||
contentEmpty: "保存済みファイルの内容を検索",
|
||||
contentBack: "検索結果に戻る",
|
||||
contentPreview: "プレビュー",
|
||||
contentOpen: "開く",
|
||||
contentLoadingPreview: "プレビューを読み込み中...",
|
||||
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
|
||||
contentScopeDetail:
|
||||
"非表示・未追跡のファイルを含む保存済み UTF-8 ファイルを検索します。除外ルールに従い、シンボリックリンクと未保存の編集は検索しません。",
|
||||
contentScope: "1 MiB 以下の保存済みファイル · 大文字小文字を区別しない",
|
||||
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
|
||||
|
||||
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "파일 내용 검색",
|
||||
content: "파일 내용",
|
||||
contentPlaceholder: "저장된 파일 내용 검색...",
|
||||
contentUnavailable: "작업 공간을 사용할 수 없음",
|
||||
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
|
||||
contentEmpty: "저장된 파일 내용 검색",
|
||||
contentBack: "결과로 돌아가기",
|
||||
contentPreview: "미리보기",
|
||||
contentOpen: "열기",
|
||||
contentLoadingPreview: "미리보기 로딩 중...",
|
||||
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
|
||||
contentScopeDetail:
|
||||
"숨김 파일과 추적되지 않은 파일을 포함한 저장된 UTF-8 파일을 검색합니다. 무시 규칙을 따르며 심볼릭 링크와 저장되지 않은 편집은 제외합니다.",
|
||||
contentScope: "1 MiB 이하 저장된 파일 · 대소문자 구분 안 함",
|
||||
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
|
||||
|
||||
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar conteúdo dos arquivos",
|
||||
content: "Conteúdo dos arquivos",
|
||||
contentPlaceholder: "Buscar conteúdo salvo...",
|
||||
contentUnavailable: "Espaço de trabalho indisponível",
|
||||
contentUpdateHost: "Atualize este host para buscar conteúdo",
|
||||
contentEmpty: "Buscar conteúdo salvo",
|
||||
contentBack: "Voltar aos resultados",
|
||||
contentPreview: "Prévia",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Carregando prévia...",
|
||||
contentLimited: "Resultados limitados — refine a busca",
|
||||
contentScopeDetail:
|
||||
"Pesquisa arquivos UTF-8 salvos, incluindo ocultos e não rastreados. Respeita as regras de exclusão. Exclui links simbólicos e edições não salvas.",
|
||||
contentScope: "Arquivos salvos até 1 MiB · Ignorar maiúsculas",
|
||||
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
|
||||
|
||||
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Поиск по содержимому файлов",
|
||||
content: "Содержимое файлов",
|
||||
contentPlaceholder: "Поиск в сохранённых файлах...",
|
||||
contentUnavailable: "Рабочее пространство недоступно",
|
||||
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
|
||||
contentEmpty: "Поиск в сохранённых файлах",
|
||||
contentBack: "Назад к результатам",
|
||||
contentPreview: "Предпросмотр",
|
||||
contentOpen: "Открыть",
|
||||
contentLoadingPreview: "Загрузка предпросмотра...",
|
||||
contentLimited: "Результаты ограничены — уточните поиск",
|
||||
contentScopeDetail:
|
||||
"Поиск в сохранённых файлах UTF-8, включая скрытые и неотслеживаемые. Учитываются правила исключения. Символические ссылки и несохранённые изменения исключены.",
|
||||
contentScope: "Сохранённые файлы до 1 МиБ · Без учёта регистра",
|
||||
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
|
||||
|
||||
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
|
||||
|
||||
@@ -74,18 +74,14 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "搜索文件内容",
|
||||
content: "文件内容",
|
||||
contentPlaceholder: "搜索已保存的文件内容...",
|
||||
contentUnavailable: "工作区不可用",
|
||||
contentUpdateHost: "请更新此主机以搜索文件内容",
|
||||
contentEmpty: "搜索已保存的文件内容",
|
||||
contentBack: "返回结果",
|
||||
contentPreview: "预览",
|
||||
contentOpen: "打开",
|
||||
contentLoadingPreview: "正在加载预览...",
|
||||
contentLimited: "结果已达上限 — 请缩小搜索范围",
|
||||
contentScopeDetail:
|
||||
"搜索已保存的 UTF-8 文件,包括隐藏和未跟踪的文件。遵循忽略规则,不搜索符号链接和未保存的编辑。",
|
||||
contentScope: "不超过 1 MiB 的已保存文件 · 忽略大小写",
|
||||
contentChanged: "文件已更改 — 此位置不再匹配",
|
||||
|
||||
placeholder: "搜索命令、文件、工作区和 Agent...",
|
||||
|
||||
@@ -75,7 +75,12 @@ export function usePaneContext(): PaneContextValue {
|
||||
}
|
||||
|
||||
export function usePaneFocus(): PaneFocusContextValue {
|
||||
const value = useContext(PaneFocusContext);
|
||||
const value = usePaneFocusIfAny();
|
||||
invariant(value, "PaneFocusContext is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
|
||||
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
|
||||
return useContext(PaneFocusContext);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user