Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ jobs:
working-directory: apps/desktop
run: bun run install:deps

# `bun install --ignore-scripts` skips postinstalls for safety; the
# @vscode/ripgrep package uses its postinstall to download the
# platform-specific ripgrep binary. workspace-fs tests that exercise
# the streaming / multiline paths need that binary, so run the
# postinstall explicitly for just this package.
- name: Download bundled ripgrep binary
run: |
rg_pkg=$(ls -d node_modules/.bun/@vscode+ripgrep@*/node_modules/@vscode/ripgrep | head -1)
if [ -z "$rg_pkg" ]; then
echo "::error::@vscode/ripgrep not found in node_modules"
exit 1
fi
node "$rg_pkg/lib/postinstall.js"
ls -la "$rg_pkg/bin" || true

- name: Test
env:
RELAY_URL: https://relay.superset.sh
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/lib/trpc/routers/filesystem/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FsContentMatch } from "@superset/workspace-fs/host";
import {
toErrorMessage,
WorkspaceFsPathError,
Expand Down Expand Up @@ -130,6 +131,9 @@ const searchContentInputSchema = z.object({
limit: z.number().optional(),
isRegex: z.boolean().optional(),
caseSensitive: z.boolean().optional(),
wholeWord: z.boolean().optional(),
multiline: z.boolean().optional(),
scopeId: z.string().optional(),
});

const replaceContentInputSchema = z.object({
Expand All @@ -141,6 +145,8 @@ const replaceContentInputSchema = z.object({
excludePattern: z.string().optional(),
isRegex: z.boolean().optional(),
caseSensitive: z.boolean().optional(),
wholeWord: z.boolean().optional(),
multiline: z.boolean().optional(),
paths: z.array(z.string()).optional(),
});

Expand Down Expand Up @@ -388,6 +394,9 @@ export const createFilesystemRouter = () => {
limit: input.limit,
isRegex: input.isRegex,
caseSensitive: input.caseSensitive,
wholeWord: input.wholeWord,
multiline: input.multiline,
scopeId: input.scopeId,
});
});
}),
Expand Down Expand Up @@ -415,11 +424,90 @@ export const createFilesystemRouter = () => {
excludePattern: input.excludePattern,
isRegex: input.isRegex,
caseSensitive: input.caseSensitive,
wholeWord: input.wholeWord,
multiline: input.multiline,
paths: input.paths,
});
});
}),

searchContentStream: publicProcedure
.input(
z.object({
workspaceId: z.string(),
query: z.string(),
includeHidden: z.boolean().optional(),
includePattern: z.string().optional(),
excludePattern: z.string().optional(),
limit: z.number().optional(),
isRegex: z.boolean().optional(),
caseSensitive: z.boolean().optional(),
wholeWord: z.boolean().optional(),
multiline: z.boolean().optional(),
scopeId: z.string().optional(),
}),
)
.subscription(({ input }) => {
return observable<{ match: FsContentMatch }>((emit) => {
const trimmed = input.query.trim();
if (!trimmed) {
emit.complete();
return () => {};
}

const service = getServiceForWorkspace(input.workspaceId);
let isDisposed = false;
const stream = service.searchContentStream({
...input,
query: trimmed,
});
const iterator = stream[Symbol.asyncIterator]();

const runCleanup = () => {
isDisposed = true;
void iterator.return?.().catch((error) => {
console.error(
"[filesystem/searchContentStream] Cleanup failed:",
{ workspaceId: input.workspaceId, error },
);
});
};

void (async () => {
try {
while (!isDisposed) {
const next = await iterator.next();
if (next.done || isDisposed) {
if (!isDisposed) emit.complete();
return;
}
try {
emit.next(next.value);
} catch (error) {
if (isClosedStreamError(error)) {
runCleanup();
return;
}
throw error;
}
}
} catch (error) {
if (!isDisposed) {
try {
emit.error(error);
} catch {
// subscriber already gone; nothing else to do.
}
}
}
})();

return () => {
runCleanup();
};
});
}),

watchPath: publicProcedure
.input(
z.object({
Expand Down
54 changes: 53 additions & 1 deletion apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import {
Expand Down Expand Up @@ -31,6 +31,57 @@ const rgExecutablePath = bundledRgPath.includes(
)
: bundledRgPath;

async function* spawnBundledRipgrep(
args: string[],
options: { cwd: string; signal?: AbortSignal },
): AsyncIterable<string> {
// Streaming counterpart to `runRipgrep`: feeds searchContentStream so the
// Search tab can render matches as ripgrep emits them. We SIGTERM the
// child on abort instead of relying on `spawn`'s `signal` option so we
// can drain cleanly without propagating an AbortError into the generator.
const child = spawn(rgExecutablePath, args, {
cwd: options.cwd,
windowsHide: true,
});

const onAbort = () => {
if (!child.killed) child.kill("SIGTERM");
};
const signal = options.signal;
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}

try {
child.stdout.setEncoding("utf8");
for await (const chunk of child.stdout as AsyncIterable<string>) {
if (signal?.aborted) return;
yield chunk;
}
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => {
if (signal?.aborted || code === null || code === 0 || code === 1) {
resolve();
} else {
const err = new Error(`ripgrep exited with code ${code}`) as Error & {
code?: number;
};
err.code = code;
reject(err);
}
});
});
} finally {
signal?.removeEventListener("abort", onAbort);
if (!child.killed) child.kill("SIGTERM");
}
}

const sharedHostServiceOptions = {
trashItem: async (absolutePath: string) => {
await shell.trashItem(absolutePath);
Expand All @@ -50,6 +101,7 @@ const sharedHostServiceOptions = {
});
return { stdout: result.stdout };
},
spawnRipgrep: spawnBundledRipgrep,
};

export function resolveWorkspaceRootPath(workspaceId: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export function SearchView({
const [replaceOpen, setReplaceOpen] = useState(false);
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [multiline, setMultiline] = useState(false);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({});
const [ignoredMatchIds, setIgnoredMatchIds] = useState<Record<string, true>>(
Expand Down Expand Up @@ -263,6 +265,12 @@ export function SearchView({
excludePattern,
isRegex,
caseSensitive,
wholeWord,
// `multiline` only meaningfully applies to regex patterns in VSCode,
// so we drop it entirely when the user isn't in regex mode. This
// lets the regex toggle control visibility and avoids wasted
// ripgrep calls with `--multiline` on fixed strings.
multiline: isRegex && multiline,
enabled: isActive,
});

Expand All @@ -282,7 +290,7 @@ export function SearchView({
() => collectFolderPaths(treeResults),
[treeResults],
);
const searchResultResetKey = `${query}\u0000${includePattern}\u0000${excludePattern}\u0000${isRegex}\u0000${caseSensitive}`;
const searchResultResetKey = `${query}\u0000${includePattern}\u0000${excludePattern}\u0000${isRegex}\u0000${caseSensitive}\u0000${wholeWord}\u0000${multiline}`;

const copySupersetLink = useCallback(
({
Expand Down Expand Up @@ -412,7 +420,14 @@ export function SearchView({
validationError === null &&
!replaceMutation.isPending &&
!writeFileMutation.isPending;
const canInlineReplace = hasQuery && validationError === null;
// The per-match inline replace applies the regex line by line, so a
// multiline pattern (e.g. `foo\nbar`) that matched across newlines can
// never be applied by that code path — it would simply report the hit
// as out-of-date. "Replace all" still works because the backend replaces
// against the full file content. Disable inline replace in that case so
// users don't silently hit the stale-match error.
const canInlineReplace =
hasQuery && validationError === null && !(isRegex && multiline);

const runReplace = useCallback(
async (paths?: string[]) => {
Expand All @@ -430,6 +445,8 @@ export function SearchView({
excludePattern,
isRegex,
caseSensitive,
wholeWord,
multiline: isRegex && multiline,
paths,
});

Expand Down Expand Up @@ -464,11 +481,13 @@ export function SearchView({
excludePattern,
includePattern,
isRegex,
multiline,
query,
replacement,
replaceMutation,
utils.filesystem.searchContent,
validationError,
wholeWord,
workspaceId,
],
);
Expand Down Expand Up @@ -503,6 +522,8 @@ export function SearchView({
line: lineMatch.line,
isRegex,
caseSensitive,
wholeWord,
multiline: isRegex && multiline,
},
Comment thread
MocA-Love marked this conversation as resolved.
);

Expand Down Expand Up @@ -552,10 +573,12 @@ export function SearchView({
[
caseSensitive,
isRegex,
multiline,
query,
replacement,
utils,
validationError,
wholeWord,
workspaceId,
writeFileMutation,
],
Expand Down Expand Up @@ -629,6 +652,8 @@ export function SearchView({
excludePattern={excludePattern}
isRegex={isRegex}
caseSensitive={caseSensitive}
wholeWord={wholeWord}
multiline={multiline}
canReplaceAll={canReplaceAll && totalMatches > 0}
isReplacing={replaceMutation.isPending || writeFileMutation.isPending}
onQueryChange={setQuery}
Expand All @@ -642,6 +667,8 @@ export function SearchView({
onToggleReplace={() => setReplaceOpen((current) => !current)}
onToggleRegex={() => setIsRegex((current) => !current)}
onToggleCaseSensitive={() => setCaseSensitive((current) => !current)}
onToggleWholeWord={() => setWholeWord((current) => !current)}
onToggleMultiline={() => setMultiline((current) => !current)}
onReplaceAll={() => {
void runReplace();
}}
Expand Down Expand Up @@ -800,6 +827,9 @@ export function SearchView({
query={query}
isRegex={isRegex}
caseSensitive={caseSensitive}
wholeWord={wholeWord}
multiline={isRegex && multiline}
replacement={replaceOpen ? replacement : undefined}
isReplacing={
replaceMutation.isPending ||
writeFileMutation.isPending
Expand Down Expand Up @@ -832,6 +862,9 @@ export function SearchView({
query={query}
isRegex={isRegex}
caseSensitive={caseSensitive}
wholeWord={wholeWord}
multiline={isRegex && multiline}
replacement={replaceOpen ? replacement : undefined}
isReplacing={
replaceMutation.isPending ||
writeFileMutation.isPending
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface SearchFileGroupProps {
query: string;
isRegex: boolean;
caseSensitive: boolean;
wholeWord?: boolean;
multiline?: boolean;
/** Forwarded to SearchMatchItem for the VSCode-style inline diff preview. */
replacement?: string;
isReplacing: boolean;
showReplaceAction: boolean;
showParentPath?: boolean;
Expand Down Expand Up @@ -75,6 +79,9 @@ export const SearchFileGroup = memo(function SearchFileGroup({
query,
isRegex,
caseSensitive,
wholeWord = false,
multiline = false,
replacement,
isReplacing,
showReplaceAction,
showParentPath = true,
Expand Down Expand Up @@ -233,6 +240,9 @@ export const SearchFileGroup = memo(function SearchFileGroup({
query={query}
isRegex={isRegex}
caseSensitive={caseSensitive}
wholeWord={wholeWord}
multiline={multiline}
replacement={replacement}
isReplaceEnabled={showReplaceAction && !isReplacing}
variant={
isTreeVariant ? "tree" : isListVariant ? "list" : "default"
Expand Down
Loading
Loading