Unified · Split
| @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { · 54 unmodified lines | ||
| 55 | 55 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, |
| 56 | 56 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, |
| 57 | 57 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, |
| 58 | + [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, | |
| 58 | 59 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, |
| 59 | 60 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, |
| 60 | 61 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { · 197 unmodified lines | ||
| 198 | 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 199 | 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 200 | 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 201 | + assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); | |
| 202 | + assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); | |
| 201 | 203 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); |
| 202 | 204 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); |
| 203 | 205 | assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); |
| @@ -547,19 +547,24 @@ const make = Effect.gen(function* () { · 546 unmodified lines | ||
| 547 | 547 | }); |
| 548 | 548 | } |
| 549 | 549 | |
| 550 | − const nextConfig = [...customConfig, ...missingDefaults]; | |
| 551 | − const cappedConfig = | |
| 552 | − nextConfig.length > MAX_KEYBINDINGS_COUNT | |
| 553 | − ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | |
| 554 | − : nextConfig; | |
| 555 | − if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | |
| 556 | − yield* Effect.logWarning("truncating keybindings config to max entries", { | |
| 550 | + // Startup backfill must never evict persisted user rules: append only | |
| 551 | + // the defaults that fit and skip the rest. | |
| 552 | + const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); | |
| 553 | + const defaultsToAppend = missingDefaults.slice(0, availableSlots); | |
| 554 | + const skippedDefaults = missingDefaults.slice(availableSlots); | |
| 555 | + if (skippedDefaults.length > 0) { | |
| 556 | + yield* Effect.logWarning("skipping default keybinding backfill at max entries", { | |
| 557 | 557 | path: keybindingsConfigPath, |
| 558 | 558 | maxEntries: MAX_KEYBINDINGS_COUNT, |
| 559 | + commands: skippedDefaults.map((rule) => rule.command), | |
| 559 | 560 | }); |
| 560 | 561 | } |
| 562 | + if (defaultsToAppend.length === 0) { | |
| 563 | + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 564 | + return; | |
| 565 | + } | |
| 561 | 566 | |
| 562 | − yield* writeConfigAtomically(cappedConfig); | |
| 567 | + yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); | |
| 563 | 568 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); |
| 564 | 569 | }), |
| 565 | 570 | ); |
| @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) · 71 unmodified lines | ||
| 72 | 72 | return result.stdout.trim(); |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | −const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => | |
| 75 | +const searchWorkspaceEntries = (input: { | |
| 76 | + cwd: string; | |
| 77 | + query: string; | |
| 78 | + limit: number; | |
| 79 | + kind?: "file" | "directory"; | |
| 80 | +}) => | |
| 76 | 81 | Effect.gen(function* () { |
| 77 | 82 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 78 | 83 | return yield* workspaceEntries.search(input); |
| @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { · 121 unmodified lines | ||
| 200 | 205 | }), |
| 201 | 206 | ); |
| 202 | 207 | |
| 208 | + it.effect("applies the file filter before limiting search results", () => | |
| 209 | + Effect.gen(function* () { | |
| 210 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); | |
| 211 | + yield* writeTextFile(cwd, "src/index.ts"); | |
| 212 | + yield* writeTextFile(cwd, "src/internal.ts"); | |
| 213 | + | |
| 214 | + const result = yield* searchWorkspaceEntries({ | |
| 215 | + cwd, | |
| 216 | + query: "src", | |
| 217 | + limit: 1, | |
| 218 | + kind: "file", | |
| 219 | + }); | |
| 220 | + | |
| 221 | + expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); | |
| 222 | + expect(result.truncated).toBe(true); | |
| 223 | + }), | |
| 224 | + ); | |
| 225 | + | |
| 226 | + it.effect("answers an empty file-filtered query with a bounded file listing", () => | |
| 227 | + Effect.gen(function* () { | |
| 228 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); | |
| 229 | + yield* writeTextFile(cwd, "src/index.ts"); | |
| 230 | + yield* writeTextFile(cwd, "README.md"); | |
| 231 | + | |
| 232 | + const result = yield* searchWorkspaceEntries({ | |
| 233 | + cwd, | |
| 234 | + query: "", | |
| 235 | + limit: 10, | |
| 236 | + kind: "file", | |
| 237 | + }); | |
| 238 | + | |
| 239 | + const paths = result.entries.map((entry) => entry.path); | |
| 240 | + expect(paths).toHaveLength(2); | |
| 241 | + expect(paths).toContain("src/index.ts"); | |
| 242 | + expect(paths).toContain("README.md"); | |
| 243 | + expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); | |
| 244 | + }), | |
| 245 | + ); | |
| 246 | + | |
| 247 | + it.effect("returns only directories for the directory filter", () => | |
| 248 | + Effect.gen(function* () { | |
| 249 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); | |
| 250 | + yield* writeTextFile(cwd, "src/index.ts"); | |
| 251 | + | |
| 252 | + const result = yield* searchWorkspaceEntries({ | |
| 253 | + cwd, | |
| 254 | + query: "src", | |
| 255 | + limit: 10, | |
| 256 | + kind: "directory", | |
| 257 | + }); | |
| 258 | + | |
| 259 | + expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); | |
| 260 | + expect(result.truncated).toBe(false); | |
| 261 | + }), | |
| 262 | + ); | |
| 263 | + | |
| 203 | 264 | it.effect("excludes gitignored paths for git repositories", () => |
| 204 | 265 | Effect.gen(function* () { |
| 205 | 266 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); |
| @@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { · 86 unmodified lines | ||
| 292 | 353 | ); |
| 293 | 354 | }); |
| 294 | 355 | |
| 356 | + describe("searchContents", () => { | |
| 357 | + it.effect("returns content matches with file paths, line numbers, and ranges", () => | |
| 358 | + Effect.gen(function* () { | |
| 359 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); | |
| 360 | + yield* writeTextFile( | |
| 361 | + cwd, | |
| 362 | + "src/shapes.ts", | |
| 363 | + "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", | |
| 364 | + ); | |
| 365 | + yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); | |
| 366 | + | |
| 367 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 368 | + const result = yield* workspaceEntries.searchContents({ | |
| 369 | + cwd, | |
| 370 | + query: "Square", | |
| 371 | + limit: 100, | |
| 372 | + caseSensitive: false, | |
| 373 | + wholeWord: true, | |
| 374 | + useRegex: false, | |
| 375 | + }); | |
| 376 | + | |
| 377 | + expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ | |
| 378 | + ["src/shapes.ts", 1], | |
| 379 | + ["src/shapes.ts", 2], | |
| 380 | + ]); | |
| 381 | + expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); | |
| 382 | + expect(result.truncated).toBe(false); | |
| 383 | + }), | |
| 384 | + ); | |
| 385 | + | |
| 386 | + it.effect("honors case sensitivity and gitignore rules", () => | |
| 387 | + Effect.gen(function* () { | |
| 388 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); | |
| 389 | + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); | |
| 390 | + yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); | |
| 391 | + yield* writeTextFile(cwd, "ignored.txt", "Square\n"); | |
| 392 | + | |
| 393 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 394 | + const result = yield* workspaceEntries.searchContents({ | |
| 395 | + cwd, | |
| 396 | + query: "Square", | |
| 397 | + limit: 100, | |
| 398 | + caseSensitive: true, | |
| 399 | + wholeWord: false, | |
| 400 | + useRegex: false, | |
| 401 | + }); | |
| 402 | + | |
| 403 | + expect(result.matches).toHaveLength(1); | |
| 404 | + expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); | |
| 405 | + }), | |
| 406 | + ); | |
| 407 | + | |
| 408 | + it.effect("filters whole-word matches by word boundaries without widening ranges", () => | |
| 409 | + Effect.gen(function* () { | |
| 410 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); | |
| 411 | + yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); | |
| 412 | + | |
| 413 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 414 | + const result = yield* workspaceEntries.searchContents({ | |
| 415 | + cwd, | |
| 416 | + query: "note", | |
| 417 | + limit: 100, | |
| 418 | + caseSensitive: true, | |
| 419 | + wholeWord: true, | |
| 420 | + useRegex: false, | |
| 421 | + }); | |
| 422 | + | |
| 423 | + // "notes", "denote", and "footnote" are word-adjacent and excluded; | |
| 424 | + // ranges cover exactly the query, never boundary characters. | |
| 425 | + expect(result.matches).toEqual([ | |
| 426 | + expect.objectContaining({ | |
| 427 | + path: "src/words.ts", | |
| 428 | + lineNumber: 1, | |
| 429 | + matchRanges: [{ start: 0, end: 4 }], | |
| 430 | + }), | |
| 431 | + expect.objectContaining({ | |
| 432 | + path: "src/words.ts", | |
| 433 | + lineNumber: 2, | |
| 434 | + matchRanges: [{ start: 9, end: 13 }], | |
| 435 | + }), | |
| 436 | + ]); | |
| 437 | + }), | |
| 438 | + ); | |
| 439 | + | |
| 440 | + it.effect("finds later whole-word matches in a file after rejected raw matches", () => | |
| 441 | + Effect.gen(function* () { | |
| 442 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); | |
| 443 | + yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); | |
| 444 | + | |
| 445 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 446 | + const result = yield* workspaceEntries.searchContents({ | |
| 447 | + cwd, | |
| 448 | + query: "foo", | |
| 449 | + limit: 1, | |
| 450 | + caseSensitive: true, | |
| 451 | + wholeWord: true, | |
| 452 | + useRegex: false, | |
| 453 | + }); | |
| 454 | + | |
| 455 | + expect(result.matches).toEqual([ | |
| 456 | + expect.objectContaining({ | |
| 457 | + path: "src/words.ts", | |
| 458 | + lineNumber: 11, | |
| 459 | + matchRanges: [{ start: 0, end: 3 }], | |
| 460 | + }), | |
| 461 | + ]); | |
| 462 | + }), | |
| 463 | + ); | |
| 464 | + | |
| 465 | + it.effect("treats astral-plane letters as whole word characters", () => | |
| 466 | + Effect.gen(function* () { | |
| 467 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); | |
| 468 | + yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); | |
| 469 | + | |
| 470 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 471 | + const result = yield* workspaceEntries.searchContents({ | |
| 472 | + cwd, | |
| 473 | + query: "foo", | |
| 474 | + limit: 100, | |
| 475 | + caseSensitive: true, | |
| 476 | + wholeWord: true, | |
| 477 | + useRegex: false, | |
| 478 | + }); | |
| 479 | + | |
| 480 | + expect(result.matches).toEqual([ | |
| 481 | + expect.objectContaining({ | |
| 482 | + path: "src/words.ts", | |
| 483 | + lineNumber: 1, | |
| 484 | + matchRanges: [{ start: 6, end: 9 }], | |
| 485 | + }), | |
| 486 | + ]); | |
| 487 | + }), | |
| 488 | + ); | |
| 489 | + | |
| 490 | + it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => | |
| 491 | + Effect.gen(function* () { | |
| 492 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); | |
| 493 | + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); | |
| 494 | + | |
| 495 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 496 | + const result = yield* workspaceEntries.searchContents({ | |
| 497 | + cwd, | |
| 498 | + query: "-foo-", | |
| 499 | + limit: 100, | |
| 500 | + caseSensitive: true, | |
| 501 | + wholeWord: true, | |
| 502 | + useRegex: false, | |
| 503 | + }); | |
| 504 | + | |
| 505 | + // Consuming-boundary regex would swallow the separating spaces and | |
| 506 | + // drop the middle occurrence; boundary post-filtering keeps all three. | |
| 507 | + expect(result.matches).toHaveLength(1); | |
| 508 | + expect(result.matches[0]).toMatchObject({ | |
| 509 | + path: "src/words.ts", | |
| 510 | + lineNumber: 1, | |
| 511 | + matchRanges: [ | |
| 512 | + { start: 0, end: 5 }, | |
| 513 | + { start: 6, end: 11 }, | |
| 514 | + { start: 12, end: 17 }, | |
| 515 | + ], | |
| 516 | + }); | |
| 517 | + }), | |
| 518 | + ); | |
| 519 | + | |
| 520 | + it.effect("matches punctuation-edged regex queries as whole words", () => | |
| 521 | + Effect.gen(function* () { | |
| 522 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); | |
| 523 | + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); | |
| 524 | + | |
| 525 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 526 | + const result = yield* workspaceEntries.searchContents({ | |
| 527 | + cwd, | |
| 528 | + query: "foo-", | |
| 529 | + limit: 100, | |
| 530 | + caseSensitive: true, | |
| 531 | + wholeWord: true, | |
| 532 | + useRegex: true, | |
| 533 | + }); | |
| 534 | + | |
| 535 | + // wholeWord + useRegex must not silently drop non-word-edged patterns | |
| 536 | + // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word | |
| 537 | + // characters at the match's left edge. | |
| 538 | + expect(result.matches).toHaveLength(1); | |
| 539 | + expect(result.matches[0]).toMatchObject({ | |
| 540 | + path: "src/words.ts", | |
| 541 | + lineNumber: 1, | |
| 542 | + matchRanges: [ | |
| 543 | + { start: 0, end: 4 }, | |
| 544 | + { start: 5, end: 9 }, | |
| 545 | + ], | |
| 546 | + }); | |
| 547 | + }), | |
| 548 | + ); | |
| 549 | + | |
| 550 | + it.effect("caps matches per file so one dense file cannot fill the page", () => | |
| 551 | + Effect.gen(function* () { | |
| 552 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); | |
| 553 | + yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); | |
| 554 | + yield* writeTextFile(cwd, "src/other.ts", "needle\n"); | |
| 555 | + | |
| 556 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 557 | + const result = yield* workspaceEntries.searchContents({ | |
| 558 | + cwd, | |
| 559 | + query: "needle", | |
| 560 | + limit: 500, | |
| 561 | + caseSensitive: true, | |
| 562 | + wholeWord: false, | |
| 563 | + useRegex: false, | |
| 564 | + }); | |
| 565 | + | |
| 566 | + const byPath = new Map<string, number>(); | |
| 567 | + for (const match of result.matches) { | |
| 568 | + byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); | |
| 569 | + } | |
| 570 | + expect(byPath.get("src/dense.ts")).toBe(100); | |
| 571 | + expect(byPath.get("src/other.ts")).toBe(1); | |
| 572 | + }), | |
| 573 | + ); | |
| 574 | + | |
| 575 | + it.effect("preserves regex escapes during case-insensitive searches", () => | |
| 576 | + Effect.gen(function* () { | |
| 577 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); | |
| 578 | + yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); | |
| 579 | + | |
| 580 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 581 | + const result = yield* workspaceEntries.searchContents({ | |
| 582 | + cwd, | |
| 583 | + query: "\\SQUARE", | |
| 584 | + limit: 100, | |
| 585 | + caseSensitive: false, | |
| 586 | + wholeWord: false, | |
| 587 | + useRegex: true, | |
| 588 | + }); | |
| 589 | + | |
| 590 | + expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); | |
| 591 | + }), | |
| 592 | + ); | |
| 593 | + | |
| 594 | + it.effect("preserves invalid regex errors during case-insensitive searches", () => | |
| 595 | + Effect.gen(function* () { | |
| 596 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); | |
| 597 | + yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); | |
| 598 | + | |
| 599 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 600 | + const result = yield* workspaceEntries.searchContents({ | |
| 601 | + cwd, | |
| 602 | + query: "foo)bar(", | |
| 603 | + limit: 100, | |
| 604 | + caseSensitive: false, | |
| 605 | + wholeWord: false, | |
| 606 | + useRegex: true, | |
| 607 | + }); | |
| 608 | + | |
| 609 | + expect(result.regexFallbackError).toBeDefined(); | |
| 610 | + expect(result.matches).toEqual([]); | |
| 611 | + }), | |
| 612 | + ); | |
| 613 | + | |
| 614 | + it.effect("maps multi-byte lines to string-indexed ranges", () => | |
| 615 | + Effect.gen(function* () { | |
| 616 | + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); | |
| 617 | + yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); | |
| 618 | + | |
| 619 | + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 620 | + const result = yield* workspaceEntries.searchContents({ | |
| 621 | + cwd, | |
| 622 | + query: "wörld", | |
| 623 | + limit: 100, | |
| 624 | + caseSensitive: true, | |
| 625 | + wholeWord: false, | |
| 626 | + useRegex: false, | |
| 627 | + }); | |
| 628 | + | |
| 629 | + expect(result.matches).toHaveLength(1); | |
| 630 | + const match = result.matches[0]!; | |
| 631 | + const range = match.matchRanges[0]!; | |
| 632 | + expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); | |
| 633 | + }), | |
| 634 | + ); | |
| 635 | + }); | |
| 636 | + | |
| 295 | 637 | describe("browse", () => { |
| 296 | 638 | it.effect("returns matching directories and excludes files", () => |
| 297 | 639 | Effect.gen(function* () { |
| @@ -14,11 +14,14 @@ import type { · 13 unmodified lines | ||
| 14 | 14 | FilesystemBrowseResult, |
| 15 | 15 | ProjectListEntriesInput, |
| 16 | 16 | ProjectListEntriesResult, |
| 17 | + ProjectSearchContentsInput, | |
| 18 | + ProjectSearchContentsResult, | |
| 17 | 19 | ProjectSearchEntriesInput, |
| 18 | 20 | ProjectSearchEntriesResult, |
| 19 | 21 | } from "@t3tools/contracts"; |
| 20 | 22 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | 23 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 24 | +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; | |
| 22 | 25 | |
| 23 | 26 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 24 | 27 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< · 68 unmodified lines | ||
| 93 | 96 | readonly search: ( |
| 94 | 97 | input: ProjectSearchEntriesInput, |
| 95 | 98 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; |
| 99 | + readonly searchContents: ( | |
| 100 | + input: ProjectSearchContentsInput, | |
| 101 | + ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>; | |
| 96 | 102 | readonly refresh: (cwd: string) => Effect.Effect<void>; |
| 97 | 103 | } |
| 98 | 104 | >()("t3/workspace/WorkspaceEntries") {} |
| @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { · 49 unmodified lines | ||
| 148 | 154 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( |
| 149 | 155 | Effect.orElseSucceed(() => cwd), |
| 150 | 156 | ); |
| 151 | − if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { | |
| 152 | − return; | |
| 153 | − } | |
| 154 | − const recoverRefreshFailure = ( | |
| 155 | − cause: | |
| 156 | − | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed | |
| 157 | − | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut | |
| 158 | − | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, | |
| 159 | − ) => | |
| 160 | − Effect.gen(function* () { | |
| 161 | − yield* Effect.logWarning("Failed to refresh workspace search index", { | |
| 162 | − cwd, | |
| 163 | − cause, | |
| 157 | + for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { | |
| 158 | + const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); | |
| 159 | + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { | |
| 160 | + continue; | |
| 161 | + } | |
| 162 | + const recoverRefreshFailure = ( | |
| 163 | + cause: | |
| 164 | + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed | |
| 165 | + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut | |
| 166 | + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, | |
| 167 | + ) => | |
| 168 | + Effect.gen(function* () { | |
| 169 | + yield* Effect.logWarning("Failed to refresh workspace search index", { | |
| 170 | + cwd, | |
| 171 | + variant, | |
| 172 | + cause, | |
| 173 | + }); | |
| 174 | + yield* workspaceSearchIndexes.invalidate(indexKey); | |
| 164 | 175 | }); |
| 165 | − yield* workspaceSearchIndexes.invalidate(normalizedCwd); | |
| 166 | − }); | |
| 167 | − yield* Effect.gen(function* () { | |
| 168 | − const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 169 | − yield* searchIndex.refresh(); | |
| 170 | − }).pipe( | |
| 171 | − Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 172 | − Effect.catchTags({ | |
| 173 | − WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, | |
| 174 | − WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, | |
| 175 | − WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, | |
| 176 | − }), | |
| 177 | − ); | |
| 176 | + yield* Effect.gen(function* () { | |
| 177 | + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 178 | + yield* searchIndex.refresh(); | |
| 179 | + }).pipe( | |
| 180 | + Effect.provide(workspaceSearchIndexes.get(indexKey)), | |
| 181 | + Effect.catchTags({ | |
| 182 | + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, | |
| 183 | + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, | |
| 184 | + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, | |
| 185 | + }), | |
| 186 | + ); | |
| 187 | + } | |
| 178 | 188 | }, |
| 179 | 189 | ); |
| 180 | 190 | |
| @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { · 49 unmodified lines | ||
| 230 | 240 | const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( |
| 231 | 241 | function* (input) { |
| 232 | 242 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); |
| 233 | − const normalizedQuery = input.query | |
| 234 | − .trim() | |
| 235 | − .toLowerCase() | |
| 236 | − .replace(/^[@./]+/, ""); | |
| 243 | + const normalizedQuery = normalizeSearchQuery(input.query, { | |
| 244 | + trimLeadingPattern: /^[@./]+/, | |
| 245 | + }); | |
| 237 | 246 | return yield* Effect.gen(function* () { |
| 238 | 247 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 239 | − return yield* searchIndex.search(normalizedQuery, input.limit); | |
| 240 | − }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); | |
| 248 | + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); | |
| 249 | + }).pipe( | |
| 250 | + Effect.provide( | |
| 251 | + workspaceSearchIndexes.get( | |
| 252 | + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), | |
| 253 | + ), | |
| 254 | + ), | |
| 255 | + ); | |
| 241 | 256 | }, |
| 242 | 257 | ); |
| 243 | 258 | |
| 259 | + const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( | |
| 260 | + "WorkspaceEntries.searchContents", | |
| 261 | + )(function* (input) { | |
| 262 | + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 263 | + return yield* Effect.gen(function* () { | |
| 264 | + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 265 | + return yield* searchIndex.searchContents(input); | |
| 266 | + }).pipe( | |
| 267 | + Effect.provide( | |
| 268 | + workspaceSearchIndexes.get( | |
| 269 | + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), | |
| 270 | + ), | |
| 271 | + ), | |
| 272 | + ); | |
| 273 | + }); | |
| 274 | + | |
| 244 | 275 | const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( |
| 245 | 276 | function* (input) { |
| 246 | 277 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); |
| 247 | 278 | return yield* Effect.gen(function* () { |
| 248 | 279 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 249 | 280 | return yield* searchIndex.list(); |
| 250 | − }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); | |
| 281 | + }).pipe( | |
| 282 | + Effect.provide( | |
| 283 | + workspaceSearchIndexes.get( | |
| 284 | + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), | |
| 285 | + ), | |
| 286 | + ), | |
| 287 | + ); | |
| 251 | 288 | }, |
| 252 | 289 | ); |
| 253 | 290 | |
| 254 | − return WorkspaceEntries.of({ browse, list, refresh, search }); | |
| 291 | + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); | |
| 255 | 292 | }); |
| 256 | 293 | |
| 257 | 294 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { FileFinder } from "@ff-labs/fff-node"; | |
| 1 | +import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; | |
| 2 | 2 | import { afterEach, expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain · 46 unmodified lines | ||
| 51 | 51 | }), |
| 52 | 52 | ); |
| 53 | 53 | |
| 54 | +it.effect("waits for the full content index warmup before returning", () => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); | |
| 57 | + const finder = { | |
| 58 | + destroy: vi.fn(), | |
| 59 | + waitForIndexReady, | |
| 60 | + } as unknown as FileFinder; | |
| 61 | + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); | |
| 62 | + | |
| 63 | + yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); | |
| 64 | + | |
| 65 | + expect(waitForIndexReady).toHaveBeenCalledWith(15_000); | |
| 66 | + }), | |
| 67 | +); | |
| 68 | + | |
| 69 | +it.effect("preserves a full-index warmup timeout as a structured error", () => | |
| 70 | + Effect.gen(function* () { | |
| 71 | + const finder = { | |
| 72 | + destroy: vi.fn(), | |
| 73 | + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), | |
| 74 | + } as unknown as FileFinder; | |
| 75 | + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); | |
| 76 | + | |
| 77 | + const error = yield* Effect.flip( | |
| 78 | + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), | |
| 79 | + ); | |
| 80 | + | |
| 81 | + expect(error).toMatchObject({ | |
| 82 | + _tag: "WorkspaceSearchIndexScanTimedOut", | |
| 83 | + cwd: "/workspace/project", | |
| 84 | + timeout: "15 seconds", | |
| 85 | + }); | |
| 86 | + }), | |
| 87 | +); | |
| 88 | + | |
| 54 | 89 | it.effect("preserves FileFinder destroy failures as structured defects", () => |
| 55 | 90 | Effect.gen(function* () { |
| 56 | 91 | const cause = new Error("native destroy failed"); |
| @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => · 1 unmodified line | ||
| 58 | 93 | destroy: vi.fn(() => { |
| 59 | 94 | throw cause; |
| 60 | 95 | }), |
| 61 | − isScanning: vi.fn(() => false), | |
| 96 | + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), | |
| 62 | 97 | } as unknown as FileFinder; |
| 63 | 98 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 64 | 99 | |
| @@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => · 20 unmodified lines | ||
| 85 | 120 | Effect.gen(function* () { |
| 86 | 121 | const searchCause = new Error("native search failed"); |
| 87 | 122 | const refreshCause = new Error("native scan failed"); |
| 123 | + const contentSearchCause = new Error("native grep failed"); | |
| 88 | 124 | const finder = { |
| 89 | 125 | destroy: vi.fn(), |
| 90 | − isScanning: vi.fn(() => false), | |
| 126 | + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), | |
| 91 | 127 | mixedSearch: vi.fn(() => { |
| 92 | 128 | throw searchCause; |
| 93 | 129 | }), |
| 130 | + grep: vi.fn(() => { | |
| 131 | + throw contentSearchCause; | |
| 132 | + }), | |
| 94 | 133 | scanFiles: vi.fn(() => { |
| 95 | 134 | throw refreshCause; |
| 96 | 135 | }), |
| @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => · 3 unmodified lines | ||
| 100 | 139 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); |
| 101 | 140 | const query = "authorization: Bearer secret-token"; |
| 102 | 141 | const searchError = yield* Effect.flip(searchIndex.search(query, 3)); |
| 142 | + const contentSearchError = yield* Effect.flip( | |
| 143 | + searchIndex.searchContents({ | |
| 144 | + query, | |
| 145 | + limit: 3, | |
| 146 | + caseSensitive: false, | |
| 147 | + wholeWord: false, | |
| 148 | + useRegex: false, | |
| 149 | + }), | |
| 150 | + ); | |
| 103 | 151 | const refreshError = yield* Effect.flip(searchIndex.refresh()); |
| 104 | 152 | |
| 105 | 153 | expect(searchError).toMatchObject({ |
| @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => · 6 unmodified lines | ||
| 112 | 160 | }); |
| 113 | 161 | expect(searchError).not.toHaveProperty("query"); |
| 114 | 162 | expect(searchError.message).not.toMatch(/Bearer|secret-token/); |
| 163 | + expect(contentSearchError).toMatchObject({ | |
| 164 | + _tag: "WorkspaceSearchIndexSearchFailed", | |
| 165 | + cwd: "/workspace/project", | |
| 166 | + queryLength: query.length, | |
| 167 | + pageSize: 3, | |
| 168 | + reason: "FileFinder.grep threw unexpectedly.", | |
| 169 | + cause: contentSearchCause, | |
| 170 | + }); | |
| 171 | + expect(contentSearchError).not.toHaveProperty("query"); | |
| 172 | + expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); | |
| 115 | 173 | expect(refreshError).toMatchObject({ |
| 116 | 174 | _tag: "WorkspaceSearchIndexRefreshFailed", |
| 117 | 175 | cwd: "/workspace/project", |
| @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => · 9 unmodified lines | ||
| 127 | 185 | Effect.gen(function* () { |
| 128 | 186 | const finder = { |
| 129 | 187 | destroy: vi.fn(), |
| 130 | − isScanning: vi.fn(() => false), | |
| 188 | + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), | |
| 131 | 189 | mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), |
| 132 | 190 | scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), |
| 133 | 191 | } as unknown as FileFinder; |
| @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => · 23 unmodified lines | ||
| 157 | 215 | }), |
| 158 | 216 | ), |
| 159 | 217 | ); |
| 218 | + | |
| 219 | +it.effect("continues whole-word searches after a filtered grep page", () => | |
| 220 | + Effect.scoped( | |
| 221 | + Effect.gen(function* () { | |
| 222 | + const nextCursor = { | |
| 223 | + __brand: "GrepCursor", | |
| 224 | + _offset: 1, | |
| 225 | + } as GrepCursor; | |
| 226 | + const grepResult = ( | |
| 227 | + lineContent: string, | |
| 228 | + matchRanges: Array<[number, number]>, | |
| 229 | + cursor: GrepCursor | null, | |
| 230 | + ): GrepResult => ({ | |
| 231 | + items: [ | |
| 232 | + { | |
| 233 | + relativePath: "src/words.ts", | |
| 234 | + fileName: "words.ts", | |
| 235 | + gitStatus: "unmodified", | |
| 236 | + size: lineContent.length, | |
| 237 | + modified: 0, | |
| 238 | + isBinary: false, | |
| 239 | + totalFrecencyScore: 0, | |
| 240 | + accessFrecencyScore: 0, | |
| 241 | + modificationFrecencyScore: 0, | |
| 242 | + lineNumber: 1, | |
| 243 | + col: 0, | |
| 244 | + byteOffset: 0, | |
| 245 | + lineContent, | |
| 246 | + matchRanges, | |
| 247 | + }, | |
| 248 | + ], | |
| 249 | + totalMatched: 1, | |
| 250 | + totalFilesSearched: 1, | |
| 251 | + totalFiles: 1, | |
| 252 | + filteredFileCount: 1, | |
| 253 | + nextCursor: cursor, | |
| 254 | + }); | |
| 255 | + const grep = vi.fn((_query: string, options?: GrepOptions) => | |
| 256 | + options?.cursor | |
| 257 | + ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } | |
| 258 | + : { | |
| 259 | + ok: true as const, | |
| 260 | + value: grepResult("needleSuffix", [[0, 6]], nextCursor), | |
| 261 | + }, | |
| 262 | + ); | |
| 263 | + const finder = { | |
| 264 | + destroy: vi.fn(), | |
| 265 | + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), | |
| 266 | + grep, | |
| 267 | + } as unknown as FileFinder; | |
| 268 | + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); | |
| 269 | + | |
| 270 | + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); | |
| 271 | + const result = yield* searchIndex.searchContents({ | |
| 272 | + query: "needle", | |
| 273 | + limit: 1, | |
| 274 | + caseSensitive: true, | |
| 275 | + wholeWord: true, | |
| 276 | + useRegex: false, | |
| 277 | + }); | |
| 278 | + | |
| 279 | + expect(result).toEqual({ | |
| 280 | + matches: [ | |
| 281 | + { | |
| 282 | + path: "src/words.ts", | |
| 283 | + lineNumber: 1, | |
| 284 | + lineContent: "needle", | |
| 285 | + matchRanges: [{ start: 0, end: 6 }], | |
| 286 | + }, | |
| 287 | + ], | |
| 288 | + truncated: false, | |
| 289 | + }); | |
| 290 | + expect(grep).toHaveBeenCalledTimes(2); | |
| 291 | + expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); | |
| 292 | + }), | |
| 293 | + ), | |
| 294 | +); | |
| @@ -1,22 +1,36 @@ | ||
| 1 | −import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; | |
| 1 | +import { | |
| 2 | + type DirItem, | |
| 3 | + type DirSearchResult, | |
| 4 | + type FileItem, | |
| 5 | + FileFinder, | |
| 6 | + type GrepCursor, | |
| 7 | + type MixedItem, | |
| 8 | + type MixedSearchResult, | |
| 9 | + type Result, | |
| 10 | + type SearchResult, | |
| 11 | +} from "@ff-labs/fff-node"; | |
| 2 | 12 | import * as Context from "effect/Context"; |
| 3 | 13 | import * as Effect from "effect/Effect"; |
| 4 | 14 | import * as Layer from "effect/Layer"; |
| 5 | 15 | import * as LayerMap from "effect/LayerMap"; |
| 6 | −import * as Schedule from "effect/Schedule"; | |
| 7 | 16 | import * as Schema from "effect/Schema"; |
| 8 | 17 | |
| 9 | 18 | import type { |
| 10 | 19 | ProjectEntry, |
| 20 | + ProjectEntryKind, | |
| 11 | 21 | ProjectListEntriesResult, |
| 22 | + ProjectSearchContentsInput, | |
| 23 | + ProjectSearchContentsResult, | |
| 12 | 24 | ProjectSearchEntriesResult, |
| 13 | 25 | } from "@t3tools/contracts"; |
| 14 | 26 | |
| 15 | 27 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 16 | 28 | const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; |
| 17 | 29 | const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; |
| 30 | +const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; | |
| 18 | 31 | const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; |
| 19 | −const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; | |
| 32 | +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; | |
| 33 | +const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; | |
| 20 | 34 | |
| 21 | 35 | export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()( |
| 22 | 36 | "WorkspaceSearchIndexCreateFailed", |
| @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< · 73 unmodified lines | ||
| 96 | 110 | readonly search: ( |
| 97 | 111 | query: string, |
| 98 | 112 | limit: number, |
| 113 | + kind?: ProjectEntryKind, | |
| 99 | 114 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>; |
| 115 | + readonly searchContents: ( | |
| 116 | + input: Omit<ProjectSearchContentsInput, "cwd">, | |
| 117 | + ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceSearchIndexSearchFailed>; | |
| 100 | 118 | readonly refresh: () => Effect.Effect< |
| 101 | 119 | void, |
| 102 | 120 | WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut |
| @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { · 26 unmodified lines | ||
| 129 | 147 | }; |
| 130 | 148 | } |
| 131 | 149 | |
| 150 | +function toFileEntry(item: FileItem): ProjectEntry | null { | |
| 151 | + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); | |
| 152 | + return normalizedPath ? { path: normalizedPath, kind: "file" } : null; | |
| 153 | +} | |
| 154 | + | |
| 155 | +function toDirectoryEntry(item: DirItem): ProjectEntry | null { | |
| 156 | + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); | |
| 157 | + return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; | |
| 158 | +} | |
| 159 | + | |
| 160 | +function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { | |
| 161 | + return { | |
| 162 | + entries: result.items | |
| 163 | + .flatMap((item) => { | |
| 164 | + const entry = toFileEntry(item); | |
| 165 | + return entry ? [entry] : []; | |
| 166 | + }) | |
| 167 | + .slice(0, limit), | |
| 168 | + truncated: result.totalMatched > limit, | |
| 169 | + }; | |
| 170 | +} | |
| 171 | + | |
| 172 | +function mapDirectorySearchResult( | |
| 173 | + result: DirSearchResult, | |
| 174 | + limit: number, | |
| 175 | +): ProjectSearchEntriesResult { | |
| 176 | + const entries = result.items.flatMap((item) => { | |
| 177 | + const entry = toDirectoryEntry(item); | |
| 178 | + return entry ? [entry] : []; | |
| 179 | + }); | |
| 180 | + const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; | |
| 181 | + return { | |
| 182 | + entries: entries.slice(0, limit), | |
| 183 | + truncated: result.totalMatched - rootDirectoryCount > limit, | |
| 184 | + }; | |
| 185 | +} | |
| 186 | + | |
| 132 | 187 | function mapMixedSearchResult( |
| 133 | 188 | result: MixedSearchResult, |
| 134 | 189 | limit: number, |
| @@ -155,6 +210,74 @@ function mapMixedSearchResult( · 20 unmodified lines | ||
| 155 | 210 | }; |
| 156 | 211 | } |
| 157 | 212 | |
| 213 | +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; | |
| 214 | + | |
| 215 | +function codePointAt(line: string, index: number): string | undefined { | |
| 216 | + const codePoint = line.codePointAt(index); | |
| 217 | + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); | |
| 218 | +} | |
| 219 | + | |
| 220 | +function codePointBefore(line: string, index: number): string | undefined { | |
| 221 | + if (index <= 0) return undefined; | |
| 222 | + const previousCodeUnit = line.charCodeAt(index - 1); | |
| 223 | + const previousIndex = | |
| 224 | + previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; | |
| 225 | + return codePointAt(line, previousIndex); | |
| 226 | +} | |
| 227 | + | |
| 228 | +function buildContentSearchQuery(input: Omit<ProjectSearchContentsInput, "cwd">): { | |
| 229 | + readonly searchQuery: string; | |
| 230 | + readonly regexMode: boolean; | |
| 231 | +} { | |
| 232 | + if (input.caseSensitive) { | |
| 233 | + return { searchQuery: input.query, regexMode: input.useRegex }; | |
| 234 | + } | |
| 235 | + // Plain mode relies on smart case: an all-lowercase needle matches | |
| 236 | + // case-insensitively. Regex mode needs an explicit inline flag instead. | |
| 237 | + return input.useRegex | |
| 238 | + ? { searchQuery: `(?i)${input.query}`, regexMode: true } | |
| 239 | + : { searchQuery: input.query.toLowerCase(), regexMode: false }; | |
| 240 | +} | |
| 241 | + | |
| 242 | +function mapContentMatchRanges( | |
| 243 | + line: string, | |
| 244 | + byteRanges: ReadonlyArray<readonly [number, number]>, | |
| 245 | +): Array<{ readonly start: number; readonly end: number }> { | |
| 246 | + const lineBytes = Buffer.from(line); | |
| 247 | + const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; | |
| 248 | + return byteRanges.map(([startByte, endByte]) => ({ | |
| 249 | + start: toStringIndex(startByte), | |
| 250 | + end: toStringIndex(endByte), | |
| 251 | + })); | |
| 252 | +} | |
| 253 | + | |
| 254 | +/** | |
| 255 | + * Whole-word filtering happens after the grep rather than by wrapping the | |
| 256 | + * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow | |
| 257 | + * the separator between adjacent matches and widen the reported ranges, and | |
| 258 | + * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a | |
| 259 | + * match edge is a word boundary when it touches the line edge, the | |
| 260 | + * neighbouring character is not a word character, or the match's own edge | |
| 261 | + * character is not a word character. | |
| 262 | + */ | |
| 263 | +function isWholeWordRange( | |
| 264 | + line: string, | |
| 265 | + range: { readonly start: number; readonly end: number }, | |
| 266 | +): boolean { | |
| 267 | + if (range.end <= range.start) return false; | |
| 268 | + const isWord = (character: string | undefined) => | |
| 269 | + character !== undefined && WORD_CHARACTER.test(character); | |
| 270 | + const leftIsBoundary = | |
| 271 | + range.start === 0 || | |
| 272 | + !isWord(codePointBefore(line, range.start)) || | |
| 273 | + !isWord(codePointAt(line, range.start)); | |
| 274 | + const rightIsBoundary = | |
| 275 | + range.end >= line.length || | |
| 276 | + !isWord(codePointAt(line, range.end)) || | |
| 277 | + !isWord(codePointBefore(line, range.end)); | |
| 278 | + return leftIsBoundary && rightIsBoundary; | |
| 279 | +} | |
| 280 | + | |
| 158 | 281 | function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] { |
| 159 | 282 | const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); |
| 160 | 283 | for (const entry of entries) { |
| @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn · 8 unmodified lines | ||
| 169 | 292 | return [...entryByPath.values()]; |
| 170 | 293 | } |
| 171 | 294 | |
| 172 | −const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { | |
| 295 | +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( | |
| 296 | + cwd: string, | |
| 297 | + variant: WorkspaceSearchIndexVariant, | |
| 298 | +) { | |
| 173 | 299 | const result = yield* Effect.try({ |
| 174 | 300 | try: () => |
| 175 | 301 | FileFinder.create({ |
| 176 | 302 | basePath: cwd, |
| 177 | 303 | disableMmapCache: true, |
| 178 | − disableContentIndexing: true, | |
| 304 | + // Content indexing costs scan CPU and memory, so only the on-demand | |
| 305 | + // content-search index pays for it; path-only consumers (file tree, | |
| 306 | + // composer path search, file picker) keep the lightweight index. | |
| 307 | + disableContentIndexing: variant !== "content", | |
| 179 | 308 | aiMode: false, |
| 180 | 309 | enableFsRootScanning: true, |
| 181 | 310 | enableHomeDirScanning: true, |
| @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c · 12 unmodified lines | ||
| 194 | 323 | }); |
| 195 | 324 | }); |
| 196 | 325 | |
| 197 | −const waitForScan = <E>(cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => | |
| 198 | − Effect.try({ | |
| 199 | − try: () => finder.isScanning(), | |
| 200 | − catch: onFailure, | |
| 201 | − }).pipe( | |
| 202 | − Effect.repeat({ | |
| 203 | − while: (scanning) => scanning, | |
| 204 | − schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), | |
| 205 | − }), | |
| 206 | − Effect.timeoutOrElse({ | |
| 207 | − duration: WORKSPACE_INDEX_SCAN_TIMEOUT, | |
| 208 | − orElse: () => | |
| 209 | − new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), | |
| 210 | − }), | |
| 211 | − Effect.withSpan("WorkspaceSearchIndex.waitForScan"), | |
| 212 | − ); | |
| 326 | +const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>( | |
| 327 | + cwd: string, | |
| 328 | + finder: FileFinder, | |
| 329 | + onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, | |
| 330 | +): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> { | |
| 331 | + const result = yield* Effect.tryPromise({ | |
| 332 | + try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), | |
| 333 | + catch: (cause) => | |
| 334 | + onFailure({ | |
| 335 | + reason: "FileFinder.waitForIndexReady rejected unexpectedly.", | |
| 336 | + cause, | |
| 337 | + }), | |
| 338 | + }); | |
| 339 | + if (!result.ok) { | |
| 340 | + return yield* Effect.fail(onFailure({ reason: result.error })); | |
| 341 | + } | |
| 342 | + if (!result.value) { | |
| 343 | + return yield* new WorkspaceSearchIndexScanTimedOut({ | |
| 344 | + cwd, | |
| 345 | + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, | |
| 346 | + }); | |
| 347 | + } | |
| 348 | +}); | |
| 213 | 349 | |
| 214 | −export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { | |
| 215 | − const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => | |
| 350 | +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( | |
| 351 | + cwd: string, | |
| 352 | + variant: WorkspaceSearchIndexVariant = "paths", | |
| 353 | +) { | |
| 354 | + const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => | |
| 216 | 355 | Effect.try({ |
| 217 | 356 | try: () => finder.destroy(), |
| 218 | 357 | catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), |
| 219 | 358 | }).pipe(Effect.orDie), |
| 220 | 359 | ); |
| 221 | − yield* waitForScan( | |
| 360 | + yield* waitForIndexReady( | |
| 222 | 361 | cwd, |
| 223 | 362 | finder, |
| 224 | − (cause) => | |
| 363 | + ({ reason, cause }) => | |
| 225 | 364 | new WorkspaceSearchIndexCreateFailed({ |
| 226 | 365 | cwd, |
| 227 | − reason: "FileFinder.isScanning threw while creating the index.", | |
| 366 | + reason, | |
| 228 | 367 | cause, |
| 229 | 368 | }), |
| 230 | 369 | ); |
| 231 | 370 | |
| 232 | − const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( | |
| 371 | + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>( | |
| 233 | 372 | query: string, |
| 234 | 373 | pageSize: number, |
| 235 | − ) { | |
| 374 | + operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", | |
| 375 | + execute: () => Result<A>, | |
| 376 | + ): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> { | |
| 236 | 377 | const result = yield* Effect.try({ |
| 237 | − try: () => finder.mixedSearch(query, { pageSize }), | |
| 378 | + try: execute, | |
| 238 | 379 | catch: (cause) => |
| 239 | 380 | new WorkspaceSearchIndexSearchFailed({ |
| 240 | 381 | cwd, |
| 241 | 382 | queryLength: query.length, |
| 242 | 383 | pageSize, |
| 243 | − reason: "FileFinder.mixedSearch threw unexpectedly.", | |
| 384 | + reason: `FileFinder.${operation} threw unexpectedly.`, | |
| 244 | 385 | cause, |
| 245 | 386 | }), |
| 246 | 387 | }); |
| @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 26 unmodified lines | ||
| 273 | 414 | reason: result.error, |
| 274 | 415 | }); |
| 275 | 416 | } |
| 276 | − yield* waitForScan( | |
| 417 | + yield* waitForIndexReady( | |
| 277 | 418 | cwd, |
| 278 | 419 | finder, |
| 279 | − (cause) => | |
| 420 | + ({ reason, cause }) => | |
| 280 | 421 | new WorkspaceSearchIndexRefreshFailed({ |
| 281 | 422 | cwd, |
| 282 | − reason: "FileFinder.isScanning threw while refreshing the index.", | |
| 423 | + reason, | |
| 283 | 424 | cause, |
| 284 | 425 | }), |
| 285 | 426 | ); |
| @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 1 unmodified line | ||
| 287 | 428 | |
| 288 | 429 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( |
| 289 | 430 | function* () { |
| 290 | − const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); | |
| 431 | + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => | |
| 432 | + finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), | |
| 433 | + ); | |
| 291 | 434 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); |
| 292 | 435 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => |
| 293 | 436 | left.path.localeCompare(right.path), |
| @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 8 unmodified lines | ||
| 302 | 445 | |
| 303 | 446 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( |
| 304 | 447 | "WorkspaceSearchIndex.search", |
| 305 | − )(function* (query, limit) { | |
| 306 | − const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); | |
| 448 | + )(function* (query, limit, kind) { | |
| 449 | + const pageSize = Math.max(1, limit + 1); | |
| 450 | + if (kind === "file") { | |
| 451 | + const result = yield* runSearch(query, pageSize, "fileSearch", () => | |
| 452 | + finder.fileSearch(query, { pageSize }), | |
| 453 | + ); | |
| 454 | + return mapFileSearchResult(result, limit); | |
| 455 | + } | |
| 456 | + if (kind === "directory") { | |
| 457 | + const result = yield* runSearch(query, pageSize, "directorySearch", () => | |
| 458 | + finder.directorySearch(query, { pageSize }), | |
| 459 | + ); | |
| 460 | + return mapDirectorySearchResult(result, limit); | |
| 461 | + } | |
| 462 | + const result = yield* runSearch(query, pageSize, "mixedSearch", () => | |
| 463 | + finder.mixedSearch(query, { pageSize }), | |
| 464 | + ); | |
| 307 | 465 | return mapMixedSearchResult(result, limit); |
| 308 | 466 | }); |
| 309 | 467 | |
| 310 | − return WorkspaceSearchIndex.of({ list, refresh, search }); | |
| 468 | + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( | |
| 469 | + "WorkspaceSearchIndex.searchContents", | |
| 470 | + )(function* (input) { | |
| 471 | + const { searchQuery, regexMode } = buildContentSearchQuery(input); | |
| 472 | + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; | |
| 473 | + // Grep cursors advance by file, so whole-word post-filtering needs enough | |
| 474 | + // raw candidates from the current file before moving to the next one. | |
| 475 | + const rawPageSize = input.wholeWord | |
| 476 | + ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) | |
| 477 | + : input.limit; | |
| 478 | + const matches: Array<ProjectSearchContentsResult["matches"][number]> = []; | |
| 479 | + let nextCursor: GrepCursor | null = null; | |
| 480 | + let regexFallbackError: string | undefined; | |
| 481 | + | |
| 482 | + do { | |
| 483 | + const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); | |
| 484 | + const result = yield* runSearch(input.query, input.limit, "grep", () => | |
| 485 | + finder.grep(searchQuery, { | |
| 486 | + mode: regexMode ? "regex" : "plain", | |
| 487 | + smartCase: !input.caseSensitive && !regexMode, | |
| 488 | + // A single dense file must not consume the whole result page. | |
| 489 | + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), | |
| 490 | + pageSize: rawPageSize, | |
| 491 | + cursor: nextCursor, | |
| 492 | + timeBudgetMs: remainingTimeBudgetMs, | |
| 493 | + }), | |
| 494 | + ); | |
| 495 | + | |
| 496 | + for (const match of result.items) { | |
| 497 | + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( | |
| 498 | + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), | |
| 499 | + ); | |
| 500 | + if (matchRanges.length === 0) continue; | |
| 501 | + matches.push({ | |
| 502 | + path: toPosixPath(match.relativePath), | |
| 503 | + lineNumber: match.lineNumber, | |
| 504 | + lineContent: match.lineContent, | |
| 505 | + matchRanges, | |
| 506 | + }); | |
| 507 | + } | |
| 508 | + nextCursor = result.nextCursor; | |
| 509 | + regexFallbackError ??= result.regexFallbackError; | |
| 510 | + } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); | |
| 511 | + | |
| 512 | + return { | |
| 513 | + matches: matches.slice(0, input.limit), | |
| 514 | + truncated: matches.length > input.limit || nextCursor !== null, | |
| 515 | + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), | |
| 516 | + }; | |
| 517 | + }); | |
| 518 | + | |
| 519 | + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); | |
| 311 | 520 | }); |
| 312 | 521 | |
| 522 | +export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; | |
| 523 | +export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; | |
| 524 | + | |
| 525 | +/** | |
| 526 | + * Composite LayerMap key so the lightweight path index and the on-demand | |
| 527 | + * content-search index of the same workspace are separate resources with | |
| 528 | + * independent lifecycles. "\n" cannot appear in a filesystem path. | |
| 529 | + */ | |
| 530 | +export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => | |
| 531 | + `${variant}\n${cwd}`; | |
| 532 | + | |
| 533 | +function parseWorkspaceSearchIndexKey(key: string): { | |
| 534 | + readonly cwd: string; | |
| 535 | + readonly variant: WorkspaceSearchIndexVariant; | |
| 536 | +} { | |
| 537 | + const separatorIndex = key.indexOf("\n"); | |
| 538 | + return { | |
| 539 | + variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, | |
| 540 | + cwd: key.slice(separatorIndex + 1), | |
| 541 | + }; | |
| 542 | +} | |
| 543 | + | |
| 313 | 544 | /** |
| 314 | 545 | * A layer factory is required because every index is scoped to a concrete |
| 315 | − * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; | |
| 316 | − * using a default cwd here would mix resources from different workspaces. | |
| 546 | + * workspace root and variant. WorkspaceSearchIndexMap owns memoization and | |
| 547 | + * idle cleanup; using a default cwd here would mix resources from different | |
| 548 | + * workspaces. | |
| 317 | 549 | */ |
| 318 | −export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); | |
| 550 | +export const layer = (key: string) => { | |
| 551 | + const { cwd, variant } = parseWorkspaceSearchIndexKey(key); | |
| 552 | + return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); | |
| 553 | +}; | |
| 319 | 554 | |
| 320 | 555 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( |
| 321 | 556 | "t3/workspace/WorkspaceSearchIndexMap", |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | ||
| 37 | 37 | type ProjectFileOperation, |
| 38 | 38 | ProjectListEntriesError, |
| 39 | 39 | ProjectReadFileError, |
| 40 | + ProjectSearchContentsError, | |
| 40 | 41 | ProjectSearchEntriesError, |
| 41 | 42 | ProjectWriteFileError, |
| 42 | 43 | RelayClientInstallFailedError, |
| @@ -1597,6 +1598,23 @@ const makeWsRpcLayer = ( · 1554 unmodified lines | ||
| 1597 | 1598 | ), |
| 1598 | 1599 | { "rpc.aggregate": "workspace" }, |
| 1599 | 1600 | ), |
| 1601 | + [WS_METHODS.projectsSearchContents]: (input) => | |
| 1602 | + observeRpcEffect( | |
| 1603 | + WS_METHODS.projectsSearchContents, | |
| 1604 | + workspaceEntries.searchContents(input).pipe( | |
| 1605 | + Effect.mapError( | |
| 1606 | + (cause) => | |
| 1607 | + new ProjectSearchContentsError({ | |
| 1608 | + cwd: input.cwd, | |
| 1609 | + queryLength: input.query.length, | |
| 1610 | + limit: input.limit, | |
| 1611 | + ...projectEntriesFailureContext(cause), | |
| 1612 | + cause, | |
| 1613 | + }), | |
| 1614 | + ), | |
| 1615 | + ), | |
| 1616 | + { "rpc.aggregate": "workspace" }, | |
| 1617 | + ), | |
| 1600 | 1618 | [WS_METHODS.projectsListEntries]: (input) => |
| 1601 | 1619 | observeRpcEffect( |
| 1602 | 1620 | WS_METHODS.projectsListEntries, |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | −import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; | |
| 3 | 2 | import { |
| 4 | 3 | CheckIcon, |
| 5 | 4 | ChevronRightIcon, |
| @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; · 51 unmodified lines | ||
| 57 | 56 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 58 | 57 | import { fnv1a32 } from "../lib/diffRendering"; |
| 59 | 58 | import { LRUCache } from "../lib/lruCache"; |
| 59 | +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | |
| 60 | +import { RenderErrorBoundary } from "./RenderErrorBoundary"; | |
| 60 | 61 | import { useTheme } from "../hooks/useTheme"; |
| 61 | 62 | import { getClientSettings } from "../hooks/useSettings"; |
| 62 | 63 | import { |
| @@ -91,27 +92,6 @@ import { · 28 unmodified lines | ||
| 91 | 92 | BrowserPreviewUnavailableError, |
| 92 | 93 | } from "../browser/openFileInPreview"; |
| 93 | 94 | |
| 94 | −class CodeHighlightErrorBoundary extends React.Component< | |
| 95 | − { fallback: ReactNode; children: ReactNode }, | |
| 96 | − { hasError: boolean } | |
| 97 | −> { | |
| 98 | − constructor(props: { fallback: ReactNode; children: ReactNode }) { | |
| 99 | − super(props); | |
| 100 | − this.state = { hasError: false }; | |
| 101 | − } | |
| 102 | − | |
| 103 | − static getDerivedStateFromError() { | |
| 104 | − return { hasError: true }; | |
| 105 | − } | |
| 106 | − | |
| 107 | − override render() { | |
| 108 | − if (this.state.hasError) { | |
| 109 | − return this.props.fallback; | |
| 110 | − } | |
| 111 | − return this.props.children; | |
| 112 | − } | |
| 113 | −} | |
| 114 | − | |
| 115 | 95 | interface ChatMarkdownProps { |
| 116 | 96 | text: string; |
| 117 | 97 | cwd: string | undefined; |
| @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>( · 29 unmodified lines | ||
| 147 | 127 | MAX_HIGHLIGHT_CACHE_ENTRIES, |
| 148 | 128 | MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, |
| 149 | 129 | ); |
| 150 | −const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>(); | |
| 151 | 130 | |
| 152 | 131 | function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { |
| 153 | 132 | const firstLineEnd = markdown.indexOf("\n", listItemStart); |
| @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { · 179 unmodified lines | ||
| 333 | 312 | return Math.max(html.length * 2, code.length * 3); |
| 334 | 313 | } |
| 335 | 314 | |
| 336 | −function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { | |
| 337 | − const cached = highlighterPromiseCache.get(language); | |
| 338 | − if (cached) return cached; | |
| 339 | − | |
| 340 | − const promise = getSharedHighlighter({ | |
| 341 | − themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], | |
| 342 | − langs: [language as SupportedLanguages], | |
| 343 | − preferredHighlighter: "shiki-js", | |
| 344 | − }).catch((err) => { | |
| 345 | − highlighterPromiseCache.delete(language); | |
| 346 | − if (language === "text") { | |
| 347 | − // "text" itself failed — Shiki cannot initialize at all, surface the error | |
| 348 | − throw err; | |
| 349 | − } | |
| 350 | − // Language not supported by Shiki — fall back to "text" | |
| 351 | − return getHighlighterPromise("text"); | |
| 352 | − }); | |
| 353 | − highlighterPromiseCache.set(language, promise); | |
| 354 | − return promise; | |
| 355 | −} | |
| 356 | − | |
| 357 | 315 | function readInitialWordWrapSetting(): boolean { |
| 358 | 316 | return getClientSettings().wordWrap; |
| 359 | 317 | } |
| @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ · 383 unmodified lines | ||
| 743 | 701 | cacheKey, |
| 744 | 702 | isStreaming, |
| 745 | 703 | }: UncachedShikiCodeBlockProps) { |
| 746 | − const highlighter = use(getHighlighterPromise(language)); | |
| 704 | + const highlighter = use(getSyntaxHighlighterPromise(language)); | |
| 747 | 705 | const highlightedHtml = useMemo(() => { |
| 748 | 706 | try { |
| 749 | 707 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| @@ -1605,7 +1563,7 @@ function ChatMarkdown({ · 855 unmodified lines | ||
| 1605 | 1563 | fenceTitle={fenceTitle} |
| 1606 | 1564 | theme={resolvedTheme} |
| 1607 | 1565 | > |
| 1608 | − <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}> | |
| 1566 | + <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}> | |
| 1609 | 1567 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 1610 | 1568 | <SuspenseShikiCodeBlock |
| 1611 | 1569 | className={codeBlock.className} |
| @@ -1614,7 +1572,7 @@ function ChatMarkdown({ · 2 unmodified lines | ||
| 1614 | 1572 | isStreaming={isStreaming} |
| 1615 | 1573 | /> |
| 1616 | 1574 | </Suspense> |
| 1617 | − </CodeHighlightErrorBoundary> | |
| 1575 | + </RenderErrorBoundary> | |
| 1618 | 1576 | </MarkdownCodeBlock> |
| 1619 | 1577 | ); |
| 1620 | 1578 | }, |
| @@ -6,9 +6,81 @@ import { · 5 unmodified lines | ||
| 6 | 6 | buildThreadActionItems, |
| 7 | 7 | enumerateCommandPaletteItems, |
| 8 | 8 | filterCommandPaletteGroups, |
| 9 | + reduceCommandPaletteUiState, | |
| 9 | 10 | type CommandPaletteGroup, |
| 10 | 11 | } from "./CommandPalette.logic"; |
| 11 | 12 | |
| 13 | +describe("reduceCommandPaletteUiState", () => { | |
| 14 | + const closedState = { open: false, mode: "command", openIntent: null } as const; | |
| 15 | + | |
| 16 | + it("toggles each overlay mode open and closed", () => { | |
| 17 | + const filesOpen = reduceCommandPaletteUiState(closedState, { | |
| 18 | + _tag: "ToggleMode", | |
| 19 | + mode: "files", | |
| 20 | + }); | |
| 21 | + expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null }); | |
| 22 | + | |
| 23 | + const contentOpen = reduceCommandPaletteUiState(filesOpen, { | |
| 24 | + _tag: "ToggleMode", | |
| 25 | + mode: "content", | |
| 26 | + }); | |
| 27 | + expect(contentOpen).toEqual({ open: true, mode: "content", openIntent: null }); | |
| 28 | + | |
| 29 | + expect( | |
| 30 | + reduceCommandPaletteUiState(contentOpen, { _tag: "ToggleMode", mode: "content" }), | |
| 31 | + ).toEqual({ open: false, mode: "command", openIntent: null }); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("switches between open modes without closing", () => { | |
| 35 | + const filesOpen = reduceCommandPaletteUiState(closedState, { | |
| 36 | + _tag: "ToggleMode", | |
| 37 | + mode: "files", | |
| 38 | + }); | |
| 39 | + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleMode", mode: "command" })).toEqual( | |
| 40 | + { | |
| 41 | + open: true, | |
| 42 | + mode: "command", | |
| 43 | + openIntent: null, | |
| 44 | + }, | |
| 45 | + ); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("routes open intents to command mode", () => { | |
| 49 | + const filesOpen = reduceCommandPaletteUiState(closedState, { | |
| 50 | + _tag: "ToggleMode", | |
| 51 | + mode: "files", | |
| 52 | + }); | |
| 53 | + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({ | |
| 54 | + open: true, | |
| 55 | + mode: "command", | |
| 56 | + openIntent: { kind: "add-project" }, | |
| 57 | + }); | |
| 58 | + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({ | |
| 59 | + open: true, | |
| 60 | + mode: "command", | |
| 61 | + openIntent: { kind: "new-thread-in" }, | |
| 62 | + }); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("resets to command mode for dialog-driven opens and closes", () => { | |
| 66 | + const filesOpen = reduceCommandPaletteUiState(closedState, { | |
| 67 | + _tag: "ToggleMode", | |
| 68 | + mode: "files", | |
| 69 | + }); | |
| 70 | + | |
| 71 | + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({ | |
| 72 | + open: false, | |
| 73 | + mode: "command", | |
| 74 | + openIntent: null, | |
| 75 | + }); | |
| 76 | + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({ | |
| 77 | + open: true, | |
| 78 | + mode: "command", | |
| 79 | + openIntent: null, | |
| 80 | + }); | |
| 81 | + }); | |
| 82 | +}); | |
| 83 | + | |
| 12 | 84 | describe("enumerateCommandPaletteItems", () => { |
| 13 | 85 | it("assigns positional jump shortcuts to the first nine displayed items", () => { |
| 14 | 86 | const items = Array.from({ length: 10 }, (_, index) => ({ |
| @@ -15,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12; · 14 unmodified lines | ||
| 15 | 15 | export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; |
| 16 | 16 | export const ADDON_ICON_CLASS = "size-4"; |
| 17 | 17 | |
| 18 | +/** | |
| 19 | + * The global search overlay hosts three mutually exclusive surfaces: the | |
| 20 | + * command palette (⌘K), the project file picker (⌘P), and project content | |
| 21 | + * search (⇧⌘F). One reducer owns open/mode state so the surfaces can never | |
| 22 | + * stack and re-triggering a mode's shortcut toggles it closed. | |
| 23 | + */ | |
| 24 | +export type SearchOverlayMode = "command" | "files" | "content"; | |
| 25 | + | |
| 26 | +export interface CommandPaletteOpenIntent { | |
| 27 | + readonly kind: "add-project" | "new-thread-in"; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export interface CommandPaletteUiState { | |
| 31 | + readonly open: boolean; | |
| 32 | + readonly mode: SearchOverlayMode; | |
| 33 | + readonly openIntent: CommandPaletteOpenIntent | null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export type CommandPaletteUiAction = | |
| 37 | + | { readonly _tag: "SetOpen"; readonly open: boolean } | |
| 38 | + | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | |
| 39 | + | { readonly _tag: "OpenAddProject" } | |
| 40 | + | { readonly _tag: "OpenNewThreadIn" } | |
| 41 | + | { readonly _tag: "ClearOpenIntent" }; | |
| 42 | + | |
| 43 | +export function reduceCommandPaletteUiState( | |
| 44 | + state: CommandPaletteUiState, | |
| 45 | + action: CommandPaletteUiAction, | |
| 46 | +): CommandPaletteUiState { | |
| 47 | + switch (action._tag) { | |
| 48 | + case "SetOpen": | |
| 49 | + return { | |
| 50 | + open: action.open, | |
| 51 | + mode: "command", | |
| 52 | + openIntent: action.open ? state.openIntent : null, | |
| 53 | + }; | |
| 54 | + case "ToggleMode": | |
| 55 | + return state.open && state.mode === action.mode | |
| 56 | + ? { open: false, mode: "command", openIntent: null } | |
| 57 | + : { open: true, mode: action.mode, openIntent: null }; | |
| 58 | + case "OpenAddProject": | |
| 59 | + return { open: true, mode: "command", openIntent: { kind: "add-project" } }; | |
| 60 | + case "OpenNewThreadIn": | |
| 61 | + return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; | |
| 62 | + case "ClearOpenIntent": | |
| 63 | + return state.openIntent ? { ...state, openIntent: null } : state; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 18 | 67 | export interface CommandPaletteThreadContentMatch { |
| 19 | 68 | readonly source: "user" | "assistant"; |
| 20 | 69 | readonly snippet: string; |
| @@ -26,7 +75,7 @@ export interface CommandPaletteItem { · 5 unmodified lines | ||
| 26 | 75 | readonly value: string; |
| 27 | 76 | readonly searchTerms: ReadonlyArray<string>; |
| 28 | 77 | readonly title: ReactNode; |
| 29 | − readonly description?: string; | |
| 78 | + readonly description?: ReactNode; | |
| 30 | 79 | readonly threadContentMatch?: CommandPaletteThreadContentMatch; |
| 31 | 80 | readonly timestamp?: string; |
| 32 | 81 | readonly icon: ReactNode; |
| @@ -28,16 +28,16 @@ import { · 27 unmodified lines | ||
| 28 | 28 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 29 | 29 | import * as Option from "effect/Option"; |
| 30 | 30 | import { |
| 31 | − ArrowDownIcon, | |
| 32 | 31 | ArrowLeftIcon, |
| 33 | − ArrowUpIcon, | |
| 34 | 32 | CornerLeftUpIcon, |
| 33 | + FileSearchIcon, | |
| 35 | 34 | FolderIcon, |
| 36 | 35 | FolderPlusIcon, |
| 37 | 36 | LinkIcon, |
| 38 | 37 | MessageSquareIcon, |
| 39 | 38 | SettingsIcon, |
| 40 | 39 | SquarePenIcon, |
| 40 | + TextSearchIcon, | |
| 41 | 41 | } from "lucide-react"; |
| 42 | 42 | import { |
| 43 | 43 | useCallback, |
| @@ -81,7 +81,9 @@ import { · 37 unmodified lines | ||
| 81 | 81 | resolveProjectPathForDispatch, |
| 82 | 82 | } from "../lib/projectPaths"; |
| 83 | 83 | import { onOpenCommandPalette } from "../commandPaletteBus"; |
| 84 | +import { isPreviewFocused } from "../lib/previewFocus"; | |
| 84 | 85 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 86 | +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | |
| 85 | 87 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; |
| 86 | 88 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; |
| 87 | 89 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| @@ -100,6 +102,7 @@ import { · 12 unmodified lines | ||
| 100 | 102 | buildThreadActionItems, |
| 101 | 103 | enumerateCommandPaletteItems, |
| 102 | 104 | type CommandPaletteActionItem, |
| 105 | + type CommandPaletteOpenIntent, | |
| 103 | 106 | type CommandPaletteSubmenuItem, |
| 104 | 107 | type CommandPaletteView, |
| 105 | 108 | filterCommandPaletteGroups, |
| @@ -107,24 +110,22 @@ import { · 1 unmodified line | ||
| 107 | 110 | getCommandPaletteMode, |
| 108 | 111 | ITEM_ICON_CLASS, |
| 109 | 112 | RECENT_THREAD_LIMIT, |
| 113 | + reduceCommandPaletteUiState, | |
| 114 | + type SearchOverlayMode, | |
| 110 | 115 | } from "./CommandPalette.logic"; |
| 111 | 116 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; |
| 112 | 117 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 118 | +import { CommandPaletteContent } from "./CommandPaletteContent"; | |
| 113 | 119 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 114 | 120 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; |
| 115 | 121 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 122 | +import { ProjectFilePicker } from "./files/ProjectFilePicker"; | |
| 123 | +import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; | |
| 116 | 124 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 117 | 125 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 118 | 126 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; |
| 119 | 127 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; |
| 120 | −import { | |
| 121 | − Command, | |
| 122 | − CommandDialog, | |
| 123 | − CommandDialogPopup, | |
| 124 | − CommandFooter, | |
| 125 | − CommandInput, | |
| 126 | − CommandPanel, | |
| 127 | −} from "./ui/command"; | |
| 128 | +import { CommandDialog, CommandDialogPopup } from "./ui/command"; | |
| 128 | 129 | import { Button } from "./ui/button"; |
| 129 | 130 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 130 | 131 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| @@ -346,50 +347,30 @@ function errorMessage(error: unknown): string { · 215 unmodified lines | ||
| 346 | 347 | return "An error occurred."; |
| 347 | 348 | } |
| 348 | 349 | |
| 349 | −interface CommandPaletteOpenIntent { | |
| 350 | − readonly kind: "add-project" | "new-thread-in"; | |
| 351 | −} | |
| 352 | − | |
| 353 | −interface CommandPaletteUiState { | |
| 354 | − readonly open: boolean; | |
| 355 | − readonly openIntent: CommandPaletteOpenIntent | null; | |
| 356 | −} | |
| 350 | +const OVERLAY_MODE_BY_COMMAND = { | |
| 351 | + "commandPalette.toggle": "command", | |
| 352 | + "filePicker.toggle": "files", | |
| 353 | + "projectSearch.toggle": "content", | |
| 354 | +} as const satisfies Partial<Record<string, SearchOverlayMode>>; | |
| 357 | 355 | |
| 358 | −type CommandPaletteUiAction = | |
| 359 | − | { readonly _tag: "SetOpen"; readonly open: boolean } | |
| 360 | − | { readonly _tag: "Toggle" } | |
| 361 | − | { readonly _tag: "OpenAddProject" } | |
| 362 | − | { readonly _tag: "OpenNewThreadIn" } | |
| 363 | − | { readonly _tag: "ClearOpenIntent" }; | |
| 364 | − | |
| 365 | −function reduceCommandPaletteUiState( | |
| 366 | − state: CommandPaletteUiState, | |
| 367 | − action: CommandPaletteUiAction, | |
| 368 | −): CommandPaletteUiState { | |
| 369 | − switch (action._tag) { | |
| 370 | − case "SetOpen": | |
| 371 | − return { | |
| 372 | − open: action.open, | |
| 373 | − openIntent: action.open ? state.openIntent : null, | |
| 374 | − }; | |
| 375 | − case "Toggle": | |
| 376 | − return { open: !state.open, openIntent: null }; | |
| 377 | − case "OpenAddProject": | |
| 378 | − return { open: true, openIntent: { kind: "add-project" } }; | |
| 379 | − case "OpenNewThreadIn": | |
| 380 | − return { open: true, openIntent: { kind: "new-thread-in" } }; | |
| 381 | − case "ClearOpenIntent": | |
| 382 | − return state.openIntent ? { ...state, openIntent: null } : state; | |
| 383 | − } | |
| 356 | +function overlayModeForCommand(command: string | null): SearchOverlayMode | null { | |
| 357 | + if (command === null) return null; | |
| 358 | + return command in OVERLAY_MODE_BY_COMMAND | |
| 359 | + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] | |
| 360 | + : null; | |
| 384 | 361 | } |
| 385 | 362 | |
| 386 | 363 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 387 | 364 | const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { |
| 388 | 365 | open: false, |
| 366 | + mode: "command", | |
| 389 | 367 | openIntent: null, |
| 390 | 368 | }); |
| 391 | 369 | const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); |
| 392 | − const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); | |
| 370 | + const toggleMode = useCallback( | |
| 371 | + (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), | |
| 372 | + [], | |
| 373 | + ); | |
| 393 | 374 | const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); |
| 394 | 375 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); |
| 395 | 376 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); |
| @@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 9 unmodified lines | ||
| 405 | 386 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 406 | 387 | : false, |
| 407 | 388 | ); |
| 389 | + const previewOpen = useRightPanelStore((state) => | |
| 390 | + routeThreadRef | |
| 391 | + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" | |
| 392 | + : false, | |
| 393 | + ); | |
| 394 | + | |
| 395 | + useEffect(() => { | |
| 396 | + if (!state.open || state.mode === "command") return; | |
| 397 | + const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 398 | + if (event.isComposing || event.key !== "Escape") return; | |
| 399 | + event.preventDefault(); | |
| 400 | + event.stopPropagation(); | |
| 401 | + toggleMode("command"); | |
| 402 | + }; | |
| 403 | + window.addEventListener("keydown", onEscapeKeyDown, true); | |
| 404 | + return () => window.removeEventListener("keydown", onEscapeKeyDown, true); | |
| 405 | + }, [state.mode, state.open, toggleMode]); | |
| 408 | 406 | |
| 409 | 407 | useEffect(() => { |
| 410 | 408 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 411 | 409 | if (event.defaultPrevented) return; |
| 410 | + // Resolve with the complete shortcut context so customized bindings | |
| 411 | + // using any documented `when` condition (e.g. previewFocus) work. | |
| 412 | 412 | const command = resolveShortcutCommand(event, keybindings, { |
| 413 | 413 | context: { |
| 414 | 414 | terminalFocus: isTerminalFocused(), |
| 415 | 415 | terminalOpen, |
| 416 | + previewFocus: isPreviewFocused(), | |
| 417 | + previewOpen, | |
| 416 | 418 | }, |
| 417 | 419 | }); |
| 418 | − if (command !== "commandPalette.toggle") { | |
| 420 | + const mode = overlayModeForCommand(command); | |
| 421 | + if (mode === null) { | |
| 419 | 422 | return; |
| 420 | 423 | } |
| 421 | 424 | event.preventDefault(); |
| 422 | 425 | event.stopPropagation(); |
| 423 | − toggleOpen(); | |
| 426 | + toggleMode(mode); | |
| 424 | 427 | }; |
| 425 | 428 | window.addEventListener("keydown", onKeyDown); |
| 426 | 429 | return () => window.removeEventListener("keydown", onKeyDown); |
| 427 | − }, [keybindings, terminalOpen, toggleOpen]); | |
| 430 | + }, [keybindings, previewOpen, terminalOpen, toggleMode]); | |
| 428 | 431 | |
| 429 | 432 | useEffect( |
| 430 | 433 | () => |
| @@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 11 unmodified lines | ||
| 442 | 445 | |
| 443 | 446 | return ( |
| 444 | 447 | <ComposerHandleContext value={composerHandleRef}> |
| 445 | − <CommandDialog open={state.open} onOpenChange={setOpen}> | |
| 448 | + <CommandDialog | |
| 449 | + open={state.open} | |
| 450 | + onOpenChange={(open, eventDetails) => { | |
| 451 | + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { | |
| 452 | + eventDetails.cancel(); | |
| 453 | + toggleMode("command"); | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + setOpen(open); | |
| 457 | + }} | |
| 458 | + > | |
| 446 | 459 | {children} |
| 447 | 460 | <CommandPaletteDialog |
| 448 | 461 | open={state.open} |
| 462 | + mode={state.mode} | |
| 449 | 463 | openIntent={state.openIntent} |
| 450 | 464 | setOpen={setOpen} |
| 465 | + openOverlayMode={toggleMode} | |
| 451 | 466 | clearOpenIntent={clearOpenIntent} |
| 452 | 467 | /> |
| 453 | 468 | </CommandDialog> |
| @@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 3 unmodified lines | ||
| 457 | 472 | |
| 458 | 473 | function CommandPaletteDialog(props: { |
| 459 | 474 | readonly open: boolean; |
| 475 | + readonly mode: SearchOverlayMode; | |
| 460 | 476 | readonly openIntent: CommandPaletteOpenIntent | null; |
| 461 | 477 | readonly setOpen: (open: boolean) => void; |
| 478 | + readonly openOverlayMode: (mode: SearchOverlayMode) => void; | |
| 462 | 479 | readonly clearOpenIntent: () => void; |
| 463 | 480 | }) { |
| 481 | + const composerHandleRef = useComposerHandleContext(); | |
| 482 | + | |
| 464 | 483 | if (!props.open) { |
| 465 | 484 | return null; |
| 466 | 485 | } |
| 467 | 486 | |
| 468 | 487 | return ( |
| 469 | − <OpenCommandPaletteDialog | |
| 470 | − openIntent={props.openIntent} | |
| 471 | − setOpen={props.setOpen} | |
| 472 | − clearOpenIntent={props.clearOpenIntent} | |
| 473 | − /> | |
| 488 | + <CommandDialogPopup | |
| 489 | + aria-label={ | |
| 490 | + props.mode === "files" | |
| 491 | + ? "File picker" | |
| 492 | + : props.mode === "content" | |
| 493 | + ? "Search project contents" | |
| 494 | + : "Command palette" | |
| 495 | + } | |
| 496 | + className={cn("overflow-hidden p-0", props.mode === "content" && "h-105")} | |
| 497 | + data-command-palette="true" | |
| 498 | + data-palette-mode={props.mode} | |
| 499 | + data-testid="command-palette" | |
| 500 | + finalFocus={() => { | |
| 501 | + composerHandleRef?.current?.focusAtEnd(); | |
| 502 | + return false; | |
| 503 | + }} | |
| 504 | + onBackdropPointerDown={() => { | |
| 505 | + props.setOpen(false); | |
| 506 | + }} | |
| 507 | + > | |
| 508 | + {props.mode === "files" ? ( | |
| 509 | + <ProjectFilePicker setOpen={props.setOpen} /> | |
| 510 | + ) : props.mode === "content" ? ( | |
| 511 | + <ProjectContentSearchDialog onOpenChange={props.setOpen} /> | |
| 512 | + ) : ( | |
| 513 | + <OpenCommandPaletteDialog | |
| 514 | + openIntent={props.openIntent} | |
| 515 | + setOpen={props.setOpen} | |
| 516 | + openOverlayMode={props.openOverlayMode} | |
| 517 | + clearOpenIntent={props.clearOpenIntent} | |
| 518 | + /> | |
| 519 | + )} | |
| 520 | + </CommandDialogPopup> | |
| 474 | 521 | ); |
| 475 | 522 | } |
| 476 | 523 | |
| 477 | 524 | function OpenCommandPaletteDialog(props: { |
| 478 | 525 | readonly openIntent: CommandPaletteOpenIntent | null; |
| 479 | 526 | readonly setOpen: (open: boolean) => void; |
| 527 | + readonly openOverlayMode: (mode: SearchOverlayMode) => void; | |
| 480 | 528 | readonly clearOpenIntent: () => void; |
| 481 | 529 | }) { |
| 482 | 530 | const navigate = useNavigate(); |
| 483 | − const { clearOpenIntent, openIntent, setOpen } = props; | |
| 484 | − const composerHandleRef = useComposerHandleContext(); | |
| 531 | + const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; | |
| 485 | 532 | const [query, setQuery] = useState(""); |
| 486 | 533 | const deferredQuery = useDeferredValue(query); |
| 487 | 534 | const isActionsOnly = deferredQuery.startsWith(">"); |
| @@ -1346,6 +1393,32 @@ function OpenCommandPaletteDialog(props: { · 858 unmodified lines | ||
| 1346 | 1393 | }); |
| 1347 | 1394 | } |
| 1348 | 1395 | |
| 1396 | + actionItems.push({ | |
| 1397 | + kind: "action", | |
| 1398 | + value: "action:open-file-picker", | |
| 1399 | + searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"], | |
| 1400 | + title: "Go to file", | |
| 1401 | + icon: <FileSearchIcon className={ITEM_ICON_CLASS} />, | |
| 1402 | + keepOpen: true, | |
| 1403 | + shortcutCommand: "filePicker.toggle", | |
| 1404 | + run: async () => { | |
| 1405 | + openOverlayMode("files"); | |
| 1406 | + }, | |
| 1407 | + }); | |
| 1408 | + | |
| 1409 | + actionItems.push({ | |
| 1410 | + kind: "action", | |
| 1411 | + value: "action:search-project-contents", | |
| 1412 | + searchTerms: ["search project", "find in files", "grep", "content search", "text search"], | |
| 1413 | + title: "Search project contents", | |
| 1414 | + icon: <TextSearchIcon className={ITEM_ICON_CLASS} />, | |
| 1415 | + keepOpen: true, | |
| 1416 | + shortcutCommand: "projectSearch.toggle", | |
| 1417 | + run: async () => { | |
| 1418 | + openOverlayMode("content"); | |
| 1419 | + }, | |
| 1420 | + }); | |
| 1421 | + | |
| 1349 | 1422 | actionItems.push({ |
| 1350 | 1423 | kind: "action", |
| 1351 | 1424 | value: "action:add-project", |
| @@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: { · 710 unmodified lines | ||
| 2062 | 2135 | primaryEnvironmentId, |
| 2063 | 2136 | ]); |
| 2064 | 2137 | |
| 2138 | + const inputAccessory = | |
| 2139 | + addProjectCloneFlow?.step === "repository" ? ( | |
| 2140 | + <Tooltip> | |
| 2141 | + <TooltipTrigger | |
| 2142 | + render={ | |
| 2143 | + <Button | |
| 2144 | + variant="outline" | |
| 2145 | + size="xs" | |
| 2146 | + tabIndex={-1} | |
| 2147 | + className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | |
| 2148 | + aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 2149 | + disabled={!canSubmitRemoteProjectFlow} | |
| 2150 | + onMouseDown={(event) => { | |
| 2151 | + event.preventDefault(); | |
| 2152 | + }} | |
| 2153 | + onClick={() => { | |
| 2154 | + void submitAddProjectCloneFlow(); | |
| 2155 | + }} | |
| 2156 | + /> | |
| 2157 | + } | |
| 2158 | + > | |
| 2159 | + <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span> | |
| 2160 | + <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 2161 | + <Kbd>Enter</Kbd> | |
| 2162 | + </KbdGroup> | |
| 2163 | + </TooltipTrigger> | |
| 2164 | + <TooltipPopup side="top">{remoteProjectButtonLabel ?? "Continue"} (Enter)</TooltipPopup> | |
| 2165 | + </Tooltip> | |
| 2166 | + ) : isBrowsing ? ( | |
| 2167 | + <Tooltip> | |
| 2168 | + <TooltipTrigger | |
| 2169 | + render={ | |
| 2170 | + <Button | |
| 2171 | + variant="outline" | |
| 2172 | + size="xs" | |
| 2173 | + tabIndex={-1} | |
| 2174 | + className={cn( | |
| 2175 | + "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", | |
| 2176 | + hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | |
| 2177 | + )} | |
| 2178 | + aria-label={`${submitActionLabel} (${addShortcutLabel})`} | |
| 2179 | + disabled={ | |
| 2180 | + !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || | |
| 2181 | + relativePathNeedsActiveProject || | |
| 2182 | + (isCloneDestinationStep && isRemoteProjectPending) | |
| 2183 | + } | |
| 2184 | + onMouseDown={(event) => { | |
| 2185 | + event.preventDefault(); | |
| 2186 | + }} | |
| 2187 | + onClick={() => { | |
| 2188 | + if (relativePathNeedsActiveProject) { | |
| 2189 | + return; | |
| 2190 | + } | |
| 2191 | + if (isCloneDestinationStep) { | |
| 2192 | + void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 2193 | + } else { | |
| 2194 | + void handleAddProject(resolvedAddProjectPath); | |
| 2195 | + } | |
| 2196 | + }} | |
| 2197 | + /> | |
| 2198 | + } | |
| 2199 | + > | |
| 2200 | + <span> | |
| 2201 | + {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | |
| 2202 | + </span> | |
| 2203 | + <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 2204 | + <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> | |
| 2205 | + </KbdGroup> | |
| 2206 | + </TooltipTrigger> | |
| 2207 | + <TooltipPopup side="top"> | |
| 2208 | + {submitActionLabel} ({addShortcutLabel}) | |
| 2209 | + </TooltipPopup> | |
| 2210 | + </Tooltip> | |
| 2211 | + ) : null; | |
| 2212 | + | |
| 2213 | + const footerActionLabel = | |
| 2214 | + addProjectCloneFlow?.step === "repository" | |
| 2215 | + ? (remoteProjectButtonLabel ?? "Continue") | |
| 2216 | + : !canSubmitBrowsePath || hasHighlightedBrowseItem | |
| 2217 | + ? "Select" | |
| 2218 | + : undefined; | |
| 2219 | + | |
| 2220 | + const footerTrailing = canOpenProjectFromFileManager ? ( | |
| 2221 | + <Button | |
| 2222 | + variant="ghost" | |
| 2223 | + size="xs" | |
| 2224 | + className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground" | |
| 2225 | + disabled={isPickingProjectFolder} | |
| 2226 | + onClick={() => { | |
| 2227 | + void handleOpenProjectFromFileManager(); | |
| 2228 | + }} | |
| 2229 | + > | |
| 2230 | + {`Open in ${fileManagerName}`} | |
| 2231 | + </Button> | |
| 2232 | + ) : null; | |
| 2233 | + | |
| 2065 | 2234 | return ( |
| 2066 | − <CommandDialogPopup | |
| 2235 | + <CommandPaletteContent | |
| 2236 | + key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | |
| 2067 | 2237 | aria-label="Command palette" |
| 2068 | − className="overflow-hidden p-0" | |
| 2069 | − data-command-palette="true" | |
| 2070 | − data-testid="command-palette" | |
| 2071 | − finalFocus={() => { | |
| 2072 | − composerHandleRef?.current?.focusAtEnd(); | |
| 2073 | − return false; | |
| 2238 | + autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} | |
| 2239 | + footerActionLabel={footerActionLabel} | |
| 2240 | + footerTrailing={footerTrailing} | |
| 2241 | + inputAccessory={inputAccessory} | |
| 2242 | + inputProps={{ | |
| 2243 | + className: | |
| 2244 | + addProjectCloneFlow?.step === "repository" | |
| 2245 | + ? "pe-32" | |
| 2246 | + : isBrowsing | |
| 2247 | + ? willCreateProjectPath | |
| 2248 | + ? "pe-36" | |
| 2249 | + : "pe-16" | |
| 2250 | + : undefined, | |
| 2251 | + placeholder: inputPlaceholder, | |
| 2252 | + wrapperClassName: isSubmenu | |
| 2253 | + ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" | |
| 2254 | + : undefined, | |
| 2255 | + ...(isSubmenu | |
| 2256 | + ? { | |
| 2257 | + startAddon: ( | |
| 2258 | + <button | |
| 2259 | + type="button" | |
| 2260 | + className="flex cursor-pointer items-center" | |
| 2261 | + aria-label="Back" | |
| 2262 | + onClick={popView} | |
| 2263 | + > | |
| 2264 | + <ArrowLeftIcon /> | |
| 2265 | + </button> | |
| 2266 | + ), | |
| 2267 | + } | |
| 2268 | + : isBrowsing | |
| 2269 | + ? { startAddon: <FolderPlusIcon /> } | |
| 2270 | + : {}), | |
| 2271 | + onKeyDown: handleKeyDown, | |
| 2074 | 2272 | }} |
| 2075 | − onBackdropPointerDown={() => { | |
| 2076 | − setOpen(false); | |
| 2273 | + mode="none" | |
| 2274 | + onItemHighlighted={(value) => { | |
| 2275 | + setHighlightedItemValue(typeof value === "string" ? value : null); | |
| 2077 | 2276 | }} |
| 2277 | + onValueChange={handleQueryChange} | |
| 2278 | + panelClassName="max-h-[min(28rem,70vh)]" | |
| 2279 | + showBackHint={isSubmenu} | |
| 2280 | + value={query} | |
| 2078 | 2281 | > |
| 2079 | − <Command | |
| 2080 | − key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | |
| 2081 | − aria-label="Command palette" | |
| 2082 | − autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} | |
| 2083 | − mode="none" | |
| 2084 | − onItemHighlighted={(value) => { | |
| 2085 | − setHighlightedItemValue(typeof value === "string" ? value : null); | |
| 2086 | − }} | |
| 2087 | − onValueChange={handleQueryChange} | |
| 2088 | − value={query} | |
| 2089 | − > | |
| 2090 | − <div className="relative"> | |
| 2091 | − <CommandInput | |
| 2092 | − className={ | |
| 2093 | − addProjectCloneFlow?.step === "repository" | |
| 2094 | − ? "pe-32" | |
| 2095 | − : isBrowsing | |
| 2096 | − ? willCreateProjectPath | |
| 2097 | − ? "pe-36" | |
| 2098 | − : "pe-16" | |
| 2099 | − : undefined | |
| 2100 | − } | |
| 2101 | − placeholder={inputPlaceholder} | |
| 2102 | − wrapperClassName={ | |
| 2103 | − isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined | |
| 2282 | + {remoteProjectContext ? ( | |
| 2283 | + <div className="p-2 pb-0"> | |
| 2284 | + <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div> | |
| 2285 | + <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5"> | |
| 2286 | + {remoteProjectContext.icon} | |
| 2287 | + <span className="flex min-w-0 flex-1 flex-col"> | |
| 2288 | + <span className="truncate text-foreground text-sm">{remoteProjectContext.title}</span> | |
| 2289 | + <span className="truncate text-muted-foreground/85 text-xs"> | |
| 2290 | + {remoteProjectContext.description} | |
| 2291 | + </span> | |
| 2292 | + </span> | |
| 2293 | + </div> | |
| 2294 | + </div> | |
| 2295 | + ) : null} | |
| 2296 | + <CommandPaletteResults | |
| 2297 | + groups={displayedGroups} | |
| 2298 | + highlightedItemValue={highlightedItemValue} | |
| 2299 | + isActionsOnly={isActionsOnly} | |
| 2300 | + keybindings={keybindings} | |
| 2301 | + onExecuteItem={executeItem} | |
| 2302 | + {...(addProjectCloneFlow?.step === "repository" | |
| 2303 | + ? { | |
| 2304 | + emptyStateMessage: | |
| 2305 | + addProjectCloneFlow.source === "url" | |
| 2306 | + ? "Enter a Git clone URL and press Enter to continue." | |
| 2307 | + : "Enter a repository path and press Enter to look it up.", | |
| 2104 | 2308 | } |
| 2105 | − {...(isSubmenu | |
| 2106 | − ? { | |
| 2107 | − startAddon: ( | |
| 2108 | − <button | |
| 2109 | − type="button" | |
| 2110 | − className="flex cursor-pointer items-center" | |
| 2111 | − aria-label="Back" | |
| 2112 | − onClick={popView} | |
| 2113 | − > | |
| 2114 | − <ArrowLeftIcon /> | |
| 2115 | − </button> | |
| 2116 | − ), | |
| 2117 | − } | |
| 2118 | − : isBrowsing && !isSubmenu | |
| 2309 | + : addProjectCloneFlow?.step === "confirm" | |
| 2310 | + ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } | |
| 2311 | + : relativePathNeedsActiveProject | |
| 2312 | + ? { emptyStateMessage: "Relative paths require an active project." } | |
| 2313 | + : willCreateProjectPath | |
| 2119 | 2314 | ? { |
| 2120 | − startAddon: <FolderPlusIcon />, | |
| 2315 | + emptyStateMessage: "Press Enter to create this folder and add it as a project.", | |
| 2121 | 2316 | } |
| 2122 | − : {})} | |
| 2123 | − onKeyDown={handleKeyDown} | |
| 2124 | − /> | |
| 2125 | − {addProjectCloneFlow?.step === "repository" ? ( | |
| 2126 | − <Tooltip> | |
| 2127 | − <TooltipTrigger | |
| 2128 | − render={ | |
| 2129 | − <Button | |
| 2130 | − variant="outline" | |
| 2131 | − size="xs" | |
| 2132 | − tabIndex={-1} | |
| 2133 | − className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | |
| 2134 | − aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 2135 | − disabled={!canSubmitRemoteProjectFlow} | |
| 2136 | − onMouseDown={(event) => { | |
| 2137 | − event.preventDefault(); | |
| 2138 | − }} | |
| 2139 | − onClick={() => { | |
| 2140 | − void submitAddProjectCloneFlow(); | |
| 2141 | − }} | |
| 2142 | − /> | |
| 2143 | − } | |
| 2144 | − > | |
| 2145 | − <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span> | |
| 2146 | − <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 2147 | − <Kbd>Enter</Kbd> | |
| 2148 | − </KbdGroup> | |
| 2149 | − </TooltipTrigger> | |
| 2150 | − <TooltipPopup side="top"> | |
| 2151 | − {remoteProjectButtonLabel ?? "Continue"} (Enter) | |
| 2152 | − </TooltipPopup> | |
| 2153 | − </Tooltip> | |
| 2154 | − ) : isBrowsing ? ( | |
| 2155 | − <Tooltip> | |
| 2156 | − <TooltipTrigger | |
| 2157 | − render={ | |
| 2158 | − <Button | |
| 2159 | − variant="outline" | |
| 2160 | − size="xs" | |
| 2161 | − tabIndex={-1} | |
| 2162 | − className={cn( | |
| 2163 | − "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", | |
| 2164 | − hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | |
| 2165 | − )} | |
| 2166 | − aria-label={`${submitActionLabel} (${addShortcutLabel})`} | |
| 2167 | − disabled={ | |
| 2168 | − !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || | |
| 2169 | − relativePathNeedsActiveProject || | |
| 2170 | − (isCloneDestinationStep && isRemoteProjectPending) | |
| 2171 | − } | |
| 2172 | − onMouseDown={(event) => { | |
| 2173 | − event.preventDefault(); | |
| 2174 | − }} | |
| 2175 | − onClick={() => { | |
| 2176 | − if (relativePathNeedsActiveProject) { | |
| 2177 | − return; | |
| 2178 | − } | |
| 2179 | − if (isCloneDestinationStep) { | |
| 2180 | − void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 2181 | − } else { | |
| 2182 | − void handleAddProject(resolvedAddProjectPath); | |
| 2183 | − } | |
| 2184 | − }} | |
| 2185 | − /> | |
| 2186 | − } | |
| 2187 | − > | |
| 2188 | − <span> | |
| 2189 | − {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | |
| 2190 | − </span> | |
| 2191 | − <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 2192 | − <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> | |
| 2193 | − </KbdGroup> | |
| 2194 | − </TooltipTrigger> | |
| 2195 | − <TooltipPopup side="top"> | |
| 2196 | − {submitActionLabel} ({addShortcutLabel}) | |
| 2197 | − </TooltipPopup> | |
| 2198 | − </Tooltip> | |
| 2199 | − ) : null} | |
| 2200 | − </div> | |
| 2201 | − <CommandPanel className="max-h-[min(28rem,70vh)]"> | |
| 2202 | − {remoteProjectContext ? ( | |
| 2203 | − <div className="p-2 pb-0"> | |
| 2204 | − <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs"> | |
| 2205 | − Repository | |
| 2206 | − </div> | |
| 2207 | − <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5"> | |
| 2208 | − {remoteProjectContext.icon} | |
| 2209 | − <span className="flex min-w-0 flex-1 flex-col"> | |
| 2210 | − <span className="truncate text-foreground text-sm"> | |
| 2211 | − {remoteProjectContext.title} | |
| 2212 | − </span> | |
| 2213 | − <span className="truncate text-muted-foreground/85 text-xs"> | |
| 2214 | − {remoteProjectContext.description} | |
| 2215 | − </span> | |
| 2216 | − </span> | |
| 2217 | − </div> | |
| 2218 | − </div> | |
| 2219 | − ) : null} | |
| 2220 | − <CommandPaletteResults | |
| 2221 | − groups={displayedGroups} | |
| 2222 | − highlightedItemValue={highlightedItemValue} | |
| 2223 | − isActionsOnly={isActionsOnly} | |
| 2224 | − keybindings={keybindings} | |
| 2225 | − onExecuteItem={executeItem} | |
| 2226 | − {...(addProjectCloneFlow?.step === "repository" | |
| 2227 | − ? { | |
| 2228 | − emptyStateMessage: | |
| 2229 | − addProjectCloneFlow.source === "url" | |
| 2230 | − ? "Enter a Git clone URL and press Enter to continue." | |
| 2231 | − : "Enter a repository path and press Enter to look it up.", | |
| 2232 | − } | |
| 2233 | − : addProjectCloneFlow?.step === "confirm" | |
| 2234 | − ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } | |
| 2235 | − : relativePathNeedsActiveProject | |
| 2236 | − ? { emptyStateMessage: "Relative paths require an active project." } | |
| 2237 | − : willCreateProjectPath | |
| 2238 | − ? { | |
| 2239 | − emptyStateMessage: | |
| 2240 | − "Press Enter to create this folder and add it as a project.", | |
| 2241 | − } | |
| 2242 | − : threadSearch.isPending | |
| 2243 | − ? { emptyStateMessage: "Searching thread messages…" } | |
| 2244 | − : {})} | |
| 2245 | − /> | |
| 2246 | − </CommandPanel> | |
| 2247 | − <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start"> | |
| 2248 | − <div className="flex items-center gap-3"> | |
| 2249 | − <KbdGroup className="items-center gap-1.5"> | |
| 2250 | − <Kbd> | |
| 2251 | − <ArrowUpIcon /> | |
| 2252 | − </Kbd> | |
| 2253 | − <Kbd> | |
| 2254 | − <ArrowDownIcon /> | |
| 2255 | − </Kbd> | |
| 2256 | − <span>Navigate</span> | |
| 2257 | − </KbdGroup> | |
| 2258 | − {addProjectCloneFlow?.step === "repository" ? ( | |
| 2259 | − <KbdGroup className="items-center gap-1.5"> | |
| 2260 | − <Kbd>Enter</Kbd> | |
| 2261 | − <span>{remoteProjectButtonLabel ?? "Continue"}</span> | |
| 2262 | − </KbdGroup> | |
| 2263 | − ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( | |
| 2264 | − <KbdGroup className="items-center gap-1.5"> | |
| 2265 | − <Kbd>Enter</Kbd> | |
| 2266 | − <span>Select</span> | |
| 2267 | − </KbdGroup> | |
| 2268 | − ) : null} | |
| 2269 | − {isSubmenu ? ( | |
| 2270 | − <KbdGroup className="items-center gap-1.5"> | |
| 2271 | − <Kbd>Backspace</Kbd> | |
| 2272 | − <span>Back</span> | |
| 2273 | − </KbdGroup> | |
| 2274 | − ) : null} | |
| 2275 | − <KbdGroup className="items-center gap-1.5"> | |
| 2276 | − <Kbd>Esc</Kbd> | |
| 2277 | − <span>Close</span> | |
| 2278 | − </KbdGroup> | |
| 2279 | − </div> | |
| 2280 | − {canOpenProjectFromFileManager ? ( | |
| 2281 | − <Button | |
| 2282 | − variant="ghost" | |
| 2283 | − size="xs" | |
| 2284 | − className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground" | |
| 2285 | − disabled={isPickingProjectFolder} | |
| 2286 | − onClick={() => { | |
| 2287 | − void handleOpenProjectFromFileManager(); | |
| 2288 | − }} | |
| 2289 | − > | |
| 2290 | − {`Open in ${fileManagerName}`} | |
| 2291 | − </Button> | |
| 2292 | − ) : null} | |
| 2293 | − </CommandFooter> | |
| 2294 | − </Command> | |
| 2295 | − </CommandDialogPopup> | |
| 2317 | + : threadSearch.isPending | |
| 2318 | + ? { emptyStateMessage: "Searching thread messages…" } | |
| 2319 | + : {})} | |
| 2320 | + /> | |
| 2321 | + </CommandPaletteContent> | |
| 2296 | 2322 | ); |
| 2297 | 2323 | } |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; | |
| 2 | +import type { ComponentProps, ReactNode } from "react"; | |
| 3 | + | |
| 4 | +import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; | |
| 5 | +import { Kbd, KbdGroup } from "./ui/kbd"; | |
| 6 | + | |
| 7 | +type CommandPaletteContentProps = Omit<ComponentProps<typeof Command>, "children"> & { | |
| 8 | + readonly children: ReactNode; | |
| 9 | + readonly escapeLabel?: ReactNode; | |
| 10 | + readonly footerActionLabel?: ReactNode; | |
| 11 | + readonly footerTrailing?: ReactNode; | |
| 12 | + readonly inputAccessory?: ReactNode; | |
| 13 | + readonly inputProps: ComponentProps<typeof CommandInput>; | |
| 14 | + readonly panelClassName?: string; | |
| 15 | + readonly showBackHint?: boolean; | |
| 16 | + readonly testId?: string; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Shared command palette chrome. Palette modes provide their query behavior, | |
| 21 | + * results, and optional input accessory while retaining one input, panel, and | |
| 22 | + * keyboard-hint gutter. | |
| 23 | + */ | |
| 24 | +export function CommandPaletteContent({ | |
| 25 | + children, | |
| 26 | + escapeLabel = "Close", | |
| 27 | + footerActionLabel, | |
| 28 | + footerTrailing, | |
| 29 | + inputAccessory, | |
| 30 | + inputProps, | |
| 31 | + panelClassName, | |
| 32 | + showBackHint, | |
| 33 | + testId, | |
| 34 | + ...commandProps | |
| 35 | +}: CommandPaletteContentProps) { | |
| 36 | + return ( | |
| 37 | + <div className="contents" data-testid={testId}> | |
| 38 | + <Command {...commandProps}> | |
| 39 | + <div className="relative"> | |
| 40 | + <CommandInput {...inputProps} /> | |
| 41 | + {inputAccessory} | |
| 42 | + </div> | |
| 43 | + <CommandPanel className={panelClassName}>{children}</CommandPanel> | |
| 44 | + <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start"> | |
| 45 | + <div className="flex items-center gap-3"> | |
| 46 | + <KbdGroup className="items-center gap-1.5"> | |
| 47 | + <Kbd> | |
| 48 | + <ArrowUpIcon /> | |
| 49 | + </Kbd> | |
| 50 | + <Kbd> | |
| 51 | + <ArrowDownIcon /> | |
| 52 | + </Kbd> | |
| 53 | + <span>Navigate</span> | |
| 54 | + </KbdGroup> | |
| 55 | + {footerActionLabel !== undefined ? ( | |
| 56 | + <KbdGroup className="items-center gap-1.5"> | |
| 57 | + <Kbd>Enter</Kbd> | |
| 58 | + <span>{footerActionLabel}</span> | |
| 59 | + </KbdGroup> | |
| 60 | + ) : null} | |
| 61 | + {showBackHint ? ( | |
| 62 | + <KbdGroup className="items-center gap-1.5"> | |
| 63 | + <Kbd>Backspace</Kbd> | |
| 64 | + <span>Back</span> | |
| 65 | + </KbdGroup> | |
| 66 | + ) : null} | |
| 67 | + <KbdGroup className="items-center gap-1.5"> | |
| 68 | + <Kbd>Esc</Kbd> | |
| 69 | + <span>{escapeLabel}</span> | |
| 70 | + </KbdGroup> | |
| 71 | + </div> | |
| 72 | + {footerTrailing} | |
| 73 | + </CommandFooter> | |
| 74 | + </Command> | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | +} | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import { Component, type ReactNode } from "react"; | |
| 2 | + | |
| 3 | +export class RenderErrorBoundary extends Component< | |
| 4 | + { readonly children: ReactNode; readonly fallback: ReactNode }, | |
| 5 | + { readonly failed: boolean } | |
| 6 | +> { | |
| 7 | + override state = { failed: false }; | |
| 8 | + | |
| 9 | + static getDerivedStateFromError() { | |
| 10 | + return { failed: true }; | |
| 11 | + } | |
| 12 | + | |
| 13 | + override render() { | |
| 14 | + return this.state.failed ? this.props.fallback : this.props.children; | |
| 15 | + } | |
| 16 | +} | |
| @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { · 25 unmodified lines | ||
| 26 | 26 | environmentId: EnvironmentId; |
| 27 | 27 | cwd: string; |
| 28 | 28 | projectName: string; |
| 29 | + /** File currently open in the preview pane; revealed and selected in the tree. */ | |
| 30 | + selectedPath: string | null; | |
| 31 | + /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ | |
| 32 | + selectedPathRevealId: number; | |
| 29 | 33 | onOpenFile: (relativePath: string) => void; |
| 30 | 34 | } |
| 31 | 35 | |
| @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ · 66 unmodified lines | ||
| 98 | 102 | environmentId, |
| 99 | 103 | cwd, |
| 100 | 104 | projectName, |
| 105 | + selectedPath, | |
| 106 | + selectedPathRevealId, | |
| 101 | 107 | onOpenFile, |
| 102 | 108 | }: FileBrowserPanelProps) { |
| 103 | 109 | const { resolvedTheme } = useTheme(); |
| @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ · 7 unmodified lines | ||
| 111 | 117 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); |
| 112 | 118 | const treePaths = useMemo(() => entries.map(treePath), [entries]); |
| 113 | 119 | const previousTreePathsRef = useRef<readonly string[]>([]); |
| 120 | + const syncingSelectionRef = useRef(false); | |
| 121 | + const treeSelectionPathRef = useRef<string | null>(null); | |
| 122 | + const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); | |
| 114 | 123 | |
| 115 | 124 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so |
| 116 | 125 | // capture the right-click position ourselves; contextmenu is a composed |
| @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ · 99 unmodified lines | ||
| 216 | 225 | initialExpansion: 1, |
| 217 | 226 | icons: T3_PIERRE_ICONS, |
| 218 | 227 | onSelectionChange: (selectedPaths) => { |
| 228 | + // The drag controller's selection cache must track every change, | |
| 229 | + // including reveal-driven ones, or drags act on a stale selection. | |
| 219 | 230 | dragMention.handleSelectionChange(selectedPaths); |
| 231 | + // Selection changes driven by the reveal sync below are echoes of an | |
| 232 | + // already-open file, not a request to open it again. | |
| 233 | + if (syncingSelectionRef.current) return; | |
| 220 | 234 | // Starting a drag selects the dragged row; that selection is a side |
| 221 | 235 | // effect of the gesture, not a request to open the file. |
| 222 | 236 | if (dragMention.isDragInProgress()) { |
| @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ · 1 unmodified line | ||
| 224 | 238 | } |
| 225 | 239 | const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); |
| 226 | 240 | if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { |
| 241 | + treeSelectionPathRef.current = selectedPath; | |
| 227 | 242 | onOpenFile(selectedPath); |
| 228 | 243 | } |
| 229 | 244 | }, |
| @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ · 17 unmodified lines | ||
| 247 | 262 | model.resetPaths(treePaths); |
| 248 | 263 | }, [entryKinds, model, treePaths]); |
| 249 | 264 | |
| 265 | + useEffect(() => { | |
| 266 | + if (!selectedPath) { | |
| 267 | + handledRevealRef.current = null; | |
| 268 | + return; | |
| 269 | + } | |
| 270 | + const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; | |
| 271 | + const handledReveal = handledRevealRef.current; | |
| 272 | + // Entry refreshes rebuild treePaths while the same preview stays open. | |
| 273 | + // Replaying a handled reveal would close an active tree search and steal focus. | |
| 274 | + if ( | |
| 275 | + handledReveal?.path === revealRequest.path && | |
| 276 | + handledReveal.revealId === revealRequest.revealId | |
| 277 | + ) { | |
| 278 | + return; | |
| 279 | + } | |
| 280 | + if (entryKinds.get(selectedPath) !== "file") return; | |
| 281 | + const selectedItem = model.getItem(selectedPath); | |
| 282 | + if (!selectedItem) return; | |
| 283 | + | |
| 284 | + // A selection that originated inside the tree (clicking a row, possibly | |
| 285 | + // in an active tree search) is already visible; re-revealing it would | |
| 286 | + // close the search and clobber the user's context. Only sync external | |
| 287 | + // opens (file picker, content search, chat links). | |
| 288 | + const selectedInTree = model | |
| 289 | + .getSelectedPaths() | |
| 290 | + .some((path) => path.replace(/\/$/, "") === selectedPath); | |
| 291 | + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { | |
| 292 | + treeSelectionPathRef.current = null; | |
| 293 | + handledRevealRef.current = revealRequest; | |
| 294 | + return; | |
| 295 | + } | |
| 296 | + treeSelectionPathRef.current = null; | |
| 297 | + handledRevealRef.current = revealRequest; | |
| 298 | + | |
| 299 | + syncingSelectionRef.current = true; | |
| 300 | + model.closeSearch(); | |
| 301 | + for (const path of model.getSelectedPaths()) { | |
| 302 | + model.getItem(path)?.deselect(); | |
| 303 | + } | |
| 304 | + | |
| 305 | + // Directory rows are registered with a trailing slash (see treePath), so | |
| 306 | + // ancestor lookups must use the same form to expand them. | |
| 307 | + const segments = selectedPath.split("/"); | |
| 308 | + let ancestorPath = ""; | |
| 309 | + for (const segment of segments.slice(0, -1)) { | |
| 310 | + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; | |
| 311 | + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); | |
| 312 | + if (item && "expand" in item) item.expand(); | |
| 313 | + } | |
| 314 | + | |
| 315 | + selectedItem.select(); | |
| 316 | + model.scrollToPath(selectedPath, { focus: true, offset: "center" }); | |
| 317 | + queueMicrotask(() => { | |
| 318 | + syncingSelectionRef.current = false; | |
| 319 | + }); | |
| 320 | + }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); | |
| 321 | + | |
| 250 | 322 | // Tag tree drags with the composer mention payload. The row is read from |
| 251 | 323 | // the composed event path (the tree's shadow root is open), so this does |
| 252 | 324 | // not depend on running after the tree's own dragstart handler; the drag |
| @@ -51,6 +51,7 @@ import { · 50 unmodified lines | ||
| 51 | 51 | remapFileCommentAnnotations, |
| 52 | 52 | } from "./fileCommentAnnotations"; |
| 53 | 53 | import { installFileEditorDismissal } from "./fileEditorDismissal"; |
| 54 | +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; | |
| 54 | 55 | import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; |
| 55 | 56 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 56 | 57 | import { fileBreadcrumbs } from "./filePath"; |
| @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): · 125 unmodified lines | ||
| 182 | 183 | ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); |
| 183 | 184 | } |
| 184 | 185 | |
| 186 | +/** | |
| 187 | + * Frames to keep retrying while the file contents or line metrics are not | |
| 188 | + * available yet (fresh mounts hydrate asynchronously). | |
| 189 | + */ | |
| 190 | +const REVEAL_MAX_ATTEMPTS = 30; | |
| 191 | +/** | |
| 192 | + * After scrolling to the target, hold it for a short window so late | |
| 193 | + * programmatic scroll resets (editable-editor focus and state restoration) | |
| 194 | + * cannot silently snap the file back to the top. Real user input cancels the | |
| 195 | + * guard immediately. | |
| 196 | + */ | |
| 197 | +const REVEAL_GUARD_FRAMES = 20; | |
| 198 | +const REVEAL_GUARD_TOLERANCE_PX = 2; | |
| 199 | + | |
| 200 | +interface FileRevealState { | |
| 201 | + frameId: number | null; | |
| 202 | + cancelGuard: (() => void) | null; | |
| 203 | + handledRequestId: number | null; | |
| 204 | + latestRequestId: number | null; | |
| 205 | +} | |
| 206 | + | |
| 185 | 207 | function useFileLineReveal( |
| 186 | 208 | relativePath: string | null, |
| 187 | 209 | revealLine: number | null, |
| 188 | 210 | revealRequestId: number, |
| 189 | 211 | ): FilePostRender { |
| 190 | − const [handledRequestIdsByPath] = useState(() => new Map<string, number>()); | |
| 191 | − const [latestRequestIdsByPath] = useState(() => new Map<string, number>()); | |
| 192 | − const [pendingFramesByPath] = useState(() => new Map<string, number>()); | |
| 212 | + const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>()); | |
| 193 | 213 | |
| 194 | 214 | return useCallback<FilePostRender>( |
| 195 | 215 | (fileContainer, instance, phase) => { |
| 196 | 216 | if (relativePath === null) return; |
| 197 | 217 | |
| 218 | + const existingState = revealStatesByPath.get(relativePath); | |
| 219 | + const state: FileRevealState = existingState ?? { | |
| 220 | + frameId: null, | |
| 221 | + cancelGuard: null, | |
| 222 | + handledRequestId: null, | |
| 223 | + latestRequestId: null, | |
| 224 | + }; | |
| 225 | + if (!existingState) revealStatesByPath.set(relativePath, state); | |
| 226 | + | |
| 198 | 227 | const cancelPendingReveal = () => { |
| 199 | − const frameId = pendingFramesByPath.get(relativePath); | |
| 200 | − if (frameId !== undefined) { | |
| 201 | − cancelAnimationFrame(frameId); | |
| 202 | − pendingFramesByPath.delete(relativePath); | |
| 228 | + if (state.frameId !== null) { | |
| 229 | + cancelAnimationFrame(state.frameId); | |
| 230 | + state.frameId = null; | |
| 203 | 231 | } |
| 232 | + state.cancelGuard?.(); | |
| 204 | 233 | }; |
| 205 | 234 | |
| 206 | 235 | if (phase === "unmount") { |
| @@ -208,18 +237,20 @@ function useFileLineReveal( · 1 unmodified line | ||
| 208 | 237 | return; |
| 209 | 238 | } |
| 210 | 239 | |
| 240 | + const contents = instance.file?.contents; | |
| 211 | 241 | const targetLine = |
| 212 | − revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); | |
| 242 | + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); | |
| 213 | 243 | updateFileLinkReveal(fileContainer, targetLine); |
| 214 | 244 | |
| 215 | 245 | if (!(instance instanceof VirtualizedFile)) return; |
| 216 | 246 | |
| 217 | − if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { | |
| 247 | + if (state.latestRequestId !== revealRequestId) { | |
| 218 | 248 | cancelPendingReveal(); |
| 219 | − latestRequestIdsByPath.set(relativePath, revealRequestId); | |
| 249 | + state.latestRequestId = revealRequestId; | |
| 250 | + state.handledRequestId = null; | |
| 220 | 251 | } |
| 221 | 252 | |
| 222 | − if (targetLine === null) { | |
| 253 | + if (revealLine === null) { | |
| 223 | 254 | fileContainer.style.minHeight = ""; |
| 224 | 255 | return; |
| 225 | 256 | } |
| @@ -230,54 +261,113 @@ function useFileLineReveal( · 4 unmodified lines | ||
| 230 | 261 | Math.max(instance.height, scrollContainer.clientHeight), |
| 231 | 262 | )}px`; |
| 232 | 263 | |
| 233 | − if ( | |
| 234 | − handledRequestIdsByPath.get(relativePath) === revealRequestId || | |
| 235 | − pendingFramesByPath.has(relativePath) | |
| 236 | − ) { | |
| 264 | + if (state.handledRequestId === revealRequestId || state.frameId !== null) { | |
| 237 | 265 | return; |
| 238 | 266 | } |
| 239 | 267 | |
| 240 | − const reveal = () => { | |
| 241 | − pendingFramesByPath.delete(relativePath); | |
| 242 | − if ( | |
| 243 | − latestRequestIdsByPath.get(relativePath) !== revealRequestId || | |
| 244 | − !fileContainer.isConnected | |
| 245 | − ) { | |
| 246 | − return; | |
| 247 | − } | |
| 248 | − | |
| 249 | − const linePosition = instance.getLinePosition(targetLine); | |
| 250 | − if (!linePosition) return; | |
| 268 | + const resolveScrollTarget = (line: number): number | null => { | |
| 269 | + const linePosition = instance.getLinePosition(line); | |
| 270 | + if (!linePosition) return null; | |
| 251 | 271 | |
| 272 | + const scrollContainerRect = scrollContainer.getBoundingClientRect(); | |
| 252 | 273 | const fileTop = |
| 253 | 274 | scrollContainer.scrollTop + |
| 254 | 275 | fileContainer.getBoundingClientRect().top - |
| 255 | − scrollContainer.getBoundingClientRect().top; | |
| 256 | − const centeredTop = Math.max( | |
| 257 | − 0, | |
| 258 | − fileTop + | |
| 259 | − linePosition.top - | |
| 260 | − Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), | |
| 261 | − ); | |
| 262 | − const maxScrollTop = Math.max( | |
| 263 | − 0, | |
| 264 | − scrollContainer.scrollHeight - scrollContainer.clientHeight, | |
| 265 | − ); | |
| 276 | + scrollContainerRect.top; | |
| 277 | + const root = fileContainer.shadowRoot ?? fileContainer; | |
| 278 | + const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`); | |
| 279 | + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); | |
| 266 | 280 | |
| 267 | − scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); | |
| 268 | − handledRequestIdsByPath.set(relativePath, revealRequestId); | |
| 281 | + return resolveCenteredFileLineScrollTop({ | |
| 282 | + scrollTop: scrollContainer.scrollTop, | |
| 283 | + scrollHeight: scrollContainer.scrollHeight, | |
| 284 | + viewportTop: scrollContainerRect.top, | |
| 285 | + viewportHeight: scrollContainer.clientHeight, | |
| 286 | + fileTop, | |
| 287 | + estimatedLine: linePosition, | |
| 288 | + ...(renderedLineRect && renderedLineRect.height > 0 | |
| 289 | + ? { | |
| 290 | + renderedLine: { | |
| 291 | + top: renderedLineRect.top, | |
| 292 | + height: renderedLineRect.height, | |
| 293 | + }, | |
| 294 | + } | |
| 295 | + : {}), | |
| 296 | + }); | |
| 269 | 297 | }; |
| 270 | 298 | |
| 271 | − pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); | |
| 299 | + const guardScrollTarget = (line: number) => { | |
| 300 | + let framesLeft = REVEAL_GUARD_FRAMES; | |
| 301 | + let guardFrameId: number | null = null; | |
| 302 | + const cancelGuard = () => { | |
| 303 | + if (guardFrameId !== null) { | |
| 304 | + cancelAnimationFrame(guardFrameId); | |
| 305 | + guardFrameId = null; | |
| 306 | + } | |
| 307 | + scrollContainer.removeEventListener("wheel", cancelGuard); | |
| 308 | + scrollContainer.removeEventListener("touchstart", cancelGuard); | |
| 309 | + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); | |
| 310 | + window.removeEventListener("keydown", cancelGuard, true); | |
| 311 | + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; | |
| 312 | + }; | |
| 313 | + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); | |
| 314 | + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); | |
| 315 | + // Pierre stops gutter pointer events from bubbling. Listen in capture | |
| 316 | + // so starting a comment cancels the reveal guard before the row expands. | |
| 317 | + scrollContainer.addEventListener("pointerdown", cancelGuard, { | |
| 318 | + passive: true, | |
| 319 | + capture: true, | |
| 320 | + }); | |
| 321 | + window.addEventListener("keydown", cancelGuard, true); | |
| 322 | + const holdTarget = () => { | |
| 323 | + guardFrameId = null; | |
| 324 | + framesLeft -= 1; | |
| 325 | + if (framesLeft <= 0 || !scrollContainer.isConnected) { | |
| 326 | + cancelGuard(); | |
| 327 | + return; | |
| 328 | + } | |
| 329 | + const targetTop = resolveScrollTarget(line); | |
| 330 | + if ( | |
| 331 | + targetTop !== null && | |
| 332 | + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX | |
| 333 | + ) { | |
| 334 | + scrollContainer.scrollTop = targetTop; | |
| 335 | + } | |
| 336 | + guardFrameId = requestAnimationFrame(holdTarget); | |
| 337 | + }; | |
| 338 | + guardFrameId = requestAnimationFrame(holdTarget); | |
| 339 | + state.cancelGuard = cancelGuard; | |
| 340 | + }; | |
| 341 | + | |
| 342 | + const scheduleReveal = (attempt: number) => { | |
| 343 | + state.frameId = requestAnimationFrame(() => { | |
| 344 | + state.frameId = null; | |
| 345 | + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { | |
| 346 | + return; | |
| 347 | + } | |
| 348 | + | |
| 349 | + // Contents and line metrics can lag the first post-render on fresh | |
| 350 | + // mounts; clamping against missing contents would scroll to line 1 | |
| 351 | + // and wrongly mark the request handled. | |
| 352 | + const currentContents = instance.file?.contents; | |
| 353 | + const line = | |
| 354 | + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); | |
| 355 | + const targetTop = line === null ? null : resolveScrollTarget(line); | |
| 356 | + if (line === null || targetTop === null) { | |
| 357 | + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); | |
| 358 | + return; | |
| 359 | + } | |
| 360 | + updateFileLinkReveal(fileContainer, line); | |
| 361 | + | |
| 362 | + scrollContainer.scrollTop = targetTop; | |
| 363 | + state.handledRequestId = revealRequestId; | |
| 364 | + guardScrollTarget(line); | |
| 365 | + }); | |
| 366 | + }; | |
| 367 | + | |
| 368 | + scheduleReveal(0); | |
| 272 | 369 | }, |
| 273 | − [ | |
| 274 | − handledRequestIdsByPath, | |
| 275 | − latestRequestIdsByPath, | |
| 276 | − pendingFramesByPath, | |
| 277 | − relativePath, | |
| 278 | − revealLine, | |
| 279 | − revealRequestId, | |
| 280 | − ], | |
| 370 | + [revealStatesByPath, relativePath, revealLine, revealRequestId], | |
| 281 | 371 | ); |
| 282 | 372 | } |
| 283 | 373 | |
| @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ · 679 unmodified lines | ||
| 963 | 1053 | environmentId={environmentId} |
| 964 | 1054 | cwd={cwd} |
| 965 | 1055 | projectName={projectName} |
| 1056 | + selectedPath={relativePath} | |
| 1057 | + selectedPathRevealId={revealRequestId} | |
| 966 | 1058 | onOpenFile={onOpenFile} |
| 967 | 1059 | /> |
| 968 | 1060 | </aside> |
| @@ -0,0 +1,83 @@ | ||
| 1 | +import { assert, describe, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic"; | |
| 4 | + | |
| 5 | +function pathsForQuery(entries: Parameters<typeof getProjectFilePickerMatches>[0], query: string) { | |
| 6 | + return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path })); | |
| 7 | +} | |
| 8 | + | |
| 9 | +const entries = [ | |
| 10 | + { kind: "directory", path: "apps/web/src" }, | |
| 11 | + { kind: "file", path: "apps/web/src/index.ts" }, | |
| 12 | + { kind: "file", path: "packages/shared/src/index.ts" }, | |
| 13 | + { kind: "file", path: "README.md" }, | |
| 14 | + { kind: "file", path: ".gitignore" }, | |
| 15 | +] as const; | |
| 16 | + | |
| 17 | +describe("getProjectFilePickerMatches", () => { | |
| 18 | + it("returns files only and preserves index order for an empty query", () => { | |
| 19 | + assert.deepEqual(pathsForQuery(entries, ""), [ | |
| 20 | + { name: "index.ts", path: "apps/web/src/index.ts" }, | |
| 21 | + { name: "index.ts", path: "packages/shared/src/index.ts" }, | |
| 22 | + { name: "README.md", path: "README.md" }, | |
| 23 | + { name: ".gitignore", path: ".gitignore" }, | |
| 24 | + ]); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("preserves the server result order", () => { | |
| 28 | + assert.deepEqual(pathsForQuery(entries, "index"), [ | |
| 29 | + { name: "index.ts", path: "apps/web/src/index.ts" }, | |
| 30 | + { name: "index.ts", path: "packages/shared/src/index.ts" }, | |
| 31 | + { name: "README.md", path: "README.md" }, | |
| 32 | + { name: ".gitignore", path: ".gitignore" }, | |
| 33 | + ]); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("supports space-separated path tokens and a result limit", () => { | |
| 37 | + assert.deepEqual( | |
| 38 | + getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({ | |
| 39 | + name, | |
| 40 | + path, | |
| 41 | + })), | |
| 42 | + [{ name: "index.ts", path: "apps/web/src/index.ts" }], | |
| 43 | + ); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("matches ordered characters while allowing skipped characters", () => { | |
| 47 | + const fuzzyEntries = [ | |
| 48 | + { kind: "file", path: "src/TestFlags.tsx" }, | |
| 49 | + { kind: "file", path: "src/SubtestFlow.tsx" }, | |
| 50 | + { kind: "file", path: "src/useSubtestFlags.ts" }, | |
| 51 | + { | |
| 52 | + kind: "file", | |
| 53 | + path: "src/useSubtestFlags/useTabActivity.ts", | |
| 54 | + }, | |
| 55 | + ] as const; | |
| 56 | + | |
| 57 | + assert.deepEqual( | |
| 58 | + pathsForQuery(fuzzyEntries, "testf").map(({ name }) => name), | |
| 59 | + ["TestFlags.tsx", "SubtestFlow.tsx", "useSubtestFlags.ts", "useTabActivity.ts"], | |
| 60 | + ); | |
| 61 | + assert.deepEqual(getProjectFilePickerMatches(fuzzyEntries, "tsfl")[0], { | |
| 62 | + name: "TestFlags.tsx", | |
| 63 | + nameMatchIndices: [0, 2, 4, 5], | |
| 64 | + path: "src/TestFlags.tsx", | |
| 65 | + pathMatchIndices: [4, 6, 8, 9], | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("uses the first ordered subsequence for highlighting", () => { | |
| 70 | + assert.deepEqual( | |
| 71 | + getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices, | |
| 72 | + [0, 2, 4], | |
| 73 | + ); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("normalizes path prefixes consistently with server search", () => { | |
| 77 | + assert.deepEqual( | |
| 78 | + getProjectFilePickerMatches([{ kind: "file", path: "src/index.ts" }], "@/src")[0] | |
| 79 | + ?.pathMatchIndices, | |
| 80 | + [0, 1, 2], | |
| 81 | + ); | |
| 82 | + }); | |
| 83 | +}); | |
| @@ -0,0 +1,73 @@ | ||
| 1 | +import type { ProjectEntry } from "@t3tools/contracts"; | |
| 2 | +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; | |
| 3 | + | |
| 4 | +export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200; | |
| 5 | + | |
| 6 | +export interface ProjectFilePickerMatch { | |
| 7 | + readonly name: string; | |
| 8 | + readonly nameMatchIndices: ReadonlyArray<number>; | |
| 9 | + readonly path: string; | |
| 10 | + readonly pathMatchIndices: ReadonlyArray<number>; | |
| 11 | +} | |
| 12 | + | |
| 13 | +function fileName(path: string): string { | |
| 14 | + return path.slice(path.lastIndexOf("/") + 1); | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * First ordered subsequence of `query` inside `value`, as highlight indices. | |
| 19 | + * Returns null when `value` does not contain the subsequence at all — the | |
| 20 | + * entry still renders (the server matched it), just without highlights. | |
| 21 | + */ | |
| 22 | +function findMatchIndices(value: string, query: string): number[] | null { | |
| 23 | + if (!query) return []; | |
| 24 | + | |
| 25 | + const normalizedValue = value.toLowerCase(); | |
| 26 | + const indices: number[] = []; | |
| 27 | + let queryIndex = 0; | |
| 28 | + | |
| 29 | + for (let valueIndex = 0; valueIndex < normalizedValue.length; valueIndex += 1) { | |
| 30 | + if (normalizedValue[valueIndex] !== query[queryIndex]) continue; | |
| 31 | + indices.push(valueIndex); | |
| 32 | + queryIndex += 1; | |
| 33 | + if (queryIndex === query.length) return indices; | |
| 34 | + } | |
| 35 | + | |
| 36 | + return null; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * Maps server search results to picker rows. Server ordering is preserved — | |
| 41 | + * ranking already happened there; this pass only filters to files and | |
| 42 | + * computes highlight positions with the same query normalization the server | |
| 43 | + * applied. | |
| 44 | + */ | |
| 45 | +export function getProjectFilePickerMatches( | |
| 46 | + entries: ReadonlyArray<ProjectEntry>, | |
| 47 | + rawQuery: string, | |
| 48 | + limit = PROJECT_FILE_PICKER_RESULT_LIMIT, | |
| 49 | +): ProjectFilePickerMatch[] { | |
| 50 | + if (limit <= 0) return []; | |
| 51 | + | |
| 52 | + const query = normalizeSearchQuery(rawQuery, { | |
| 53 | + trimLeadingPattern: /^[@./]+/, | |
| 54 | + }).replaceAll(/\s/g, ""); | |
| 55 | + const matches: ProjectFilePickerMatch[] = []; | |
| 56 | + | |
| 57 | + for (const entry of entries) { | |
| 58 | + if (entry.kind !== "file") continue; | |
| 59 | + | |
| 60 | + const name = fileName(entry.path); | |
| 61 | + const nameMatchIndices = findMatchIndices(name, query); | |
| 62 | + const pathMatchIndices = findMatchIndices(entry.path, query); | |
| 63 | + matches.push({ | |
| 64 | + name, | |
| 65 | + nameMatchIndices: nameMatchIndices ?? [], | |
| 66 | + path: entry.path, | |
| 67 | + pathMatchIndices: pathMatchIndices ?? [], | |
| 68 | + }); | |
| 69 | + if (matches.length >= limit) break; | |
| 70 | + } | |
| 71 | + | |
| 72 | + return matches; | |
| 73 | +} | |
| @@ -0,0 +1,163 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { useMemo, useState, type ReactNode } from "react"; | |
| 3 | + | |
| 4 | +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; | |
| 5 | +import { useTheme } from "~/hooks/useTheme"; | |
| 6 | +import { useRightPanelStore } from "~/rightPanelStore"; | |
| 7 | +import { primaryServerKeybindingsAtom } from "~/state/server"; | |
| 8 | + | |
| 9 | +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; | |
| 10 | +import { CommandPaletteContent } from "../CommandPaletteContent"; | |
| 11 | +import { type CommandPaletteActionItem } from "../CommandPalette.logic"; | |
| 12 | +import { CommandPaletteResults } from "../CommandPaletteResults"; | |
| 13 | +import { | |
| 14 | + getProjectFilePickerMatches, | |
| 15 | + PROJECT_FILE_PICKER_RESULT_LIMIT, | |
| 16 | +} from "./ProjectFilePicker.logic"; | |
| 17 | +import { useProjectFilePickerQuery } from "./projectFilesQueryState"; | |
| 18 | + | |
| 19 | +interface ProjectFilePickerProps { | |
| 20 | + readonly setOpen: (open: boolean) => void; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function HighlightedFuzzyText(props: { | |
| 24 | + readonly active: boolean; | |
| 25 | + readonly indices: ReadonlyArray<number>; | |
| 26 | + readonly value: string; | |
| 27 | +}) { | |
| 28 | + if (!props.active) return props.value; | |
| 29 | + | |
| 30 | + const parts: ReactNode[] = []; | |
| 31 | + let start = 0; | |
| 32 | + for (const index of props.indices) { | |
| 33 | + if (start < index) parts.push(props.value.slice(start, index)); | |
| 34 | + parts.push( | |
| 35 | + <strong className="font-semibold text-foreground" key={index}> | |
| 36 | + {props.value[index]} | |
| 37 | + </strong>, | |
| 38 | + ); | |
| 39 | + start = index + 1; | |
| 40 | + } | |
| 41 | + if (start < props.value.length) parts.push(props.value.slice(start)); | |
| 42 | + | |
| 43 | + return <span className="text-muted-foreground">{parts}</span>; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string { | |
| 47 | + if (error) return error; | |
| 48 | + const isSearching = query.trim().length > 0; | |
| 49 | + if (isPending) return isSearching ? "Searching workspace files…" : "Indexing workspace files…"; | |
| 50 | + return isSearching ? "No matching files." : "No files found."; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function EmptyProjectFilePicker() { | |
| 54 | + return ( | |
| 55 | + <CommandPaletteContent | |
| 56 | + aria-label="File picker" | |
| 57 | + escapeLabel="Back" | |
| 58 | + footerActionLabel="Open file" | |
| 59 | + inputProps={{ disabled: true, placeholder: "Search files…" }} | |
| 60 | + mode="none" | |
| 61 | + testId="project-file-picker" | |
| 62 | + value="" | |
| 63 | + > | |
| 64 | + <div className="py-10 text-center text-sm text-muted-foreground"> | |
| 65 | + Open a project to search its files. | |
| 66 | + </div> | |
| 67 | + </CommandPaletteContent> | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) { | |
| 72 | + const { target } = props; | |
| 73 | + const [query, setQuery] = useState(""); | |
| 74 | + const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); | |
| 75 | + const result = useProjectFilePickerQuery( | |
| 76 | + target.environmentId, | |
| 77 | + target.cwd, | |
| 78 | + query, | |
| 79 | + PROJECT_FILE_PICKER_RESULT_LIMIT, | |
| 80 | + ); | |
| 81 | + const { resolvedTheme } = useTheme(); | |
| 82 | + const keybindings = useAtomValue(primaryServerKeybindingsAtom); | |
| 83 | + const matches = useMemo( | |
| 84 | + () => getProjectFilePickerMatches(result.entries, result.matchedQuery), | |
| 85 | + [result.entries, result.matchedQuery], | |
| 86 | + ); | |
| 87 | + const hasMatchedQuery = /\S/.test(result.matchedQuery); | |
| 88 | + const items = useMemo<CommandPaletteActionItem[]>( | |
| 89 | + () => | |
| 90 | + matches.map((match) => ({ | |
| 91 | + kind: "action", | |
| 92 | + value: `file:${match.path}`, | |
| 93 | + searchTerms: [match.name, match.path], | |
| 94 | + title: ( | |
| 95 | + <HighlightedFuzzyText | |
| 96 | + active={hasMatchedQuery} | |
| 97 | + value={match.name} | |
| 98 | + indices={match.nameMatchIndices} | |
| 99 | + /> | |
| 100 | + ), | |
| 101 | + description: ( | |
| 102 | + <HighlightedFuzzyText | |
| 103 | + active={hasMatchedQuery} | |
| 104 | + value={match.path} | |
| 105 | + indices={match.pathMatchIndices} | |
| 106 | + /> | |
| 107 | + ), | |
| 108 | + icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />, | |
| 109 | + run: async () => { | |
| 110 | + useRightPanelStore.getState().openFile(target.threadRef, match.path); | |
| 111 | + }, | |
| 112 | + })), | |
| 113 | + [hasMatchedQuery, matches, resolvedTheme, target.threadRef], | |
| 114 | + ); | |
| 115 | + | |
| 116 | + const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending); | |
| 117 | + | |
| 118 | + return ( | |
| 119 | + <CommandPaletteContent | |
| 120 | + aria-label="File picker" | |
| 121 | + autoHighlight="always" | |
| 122 | + escapeLabel="Back" | |
| 123 | + footerActionLabel="Open file" | |
| 124 | + inputProps={{ placeholder: "Search files…" }} | |
| 125 | + mode="none" | |
| 126 | + onItemHighlighted={(value) => { | |
| 127 | + setHighlightedItemValue(typeof value === "string" ? value : null); | |
| 128 | + }} | |
| 129 | + onValueChange={(value) => { | |
| 130 | + setHighlightedItemValue(null); | |
| 131 | + setQuery(value); | |
| 132 | + }} | |
| 133 | + panelClassName="max-h-[min(34rem,76vh)]" | |
| 134 | + testId="project-file-picker" | |
| 135 | + value={query} | |
| 136 | + > | |
| 137 | + <CommandPaletteResults | |
| 138 | + groups={ | |
| 139 | + items.length > 0 ? [{ value: "project-files", label: target.projectName, items }] : [] | |
| 140 | + } | |
| 141 | + highlightedItemValue={highlightedItemValue} | |
| 142 | + isActionsOnly={false} | |
| 143 | + keybindings={keybindings} | |
| 144 | + onExecuteItem={(item) => { | |
| 145 | + if (item.kind !== "action") return; | |
| 146 | + props.setOpen(false); | |
| 147 | + void item.run(); | |
| 148 | + }} | |
| 149 | + emptyStateMessage={emptyStateMessage} | |
| 150 | + /> | |
| 151 | + </CommandPaletteContent> | |
| 152 | + ); | |
| 153 | +} | |
| 154 | + | |
| 155 | +export function ProjectFilePicker(props: ProjectFilePickerProps) { | |
| 156 | + const target = useActiveProjectTarget(); | |
| 157 | + | |
| 158 | + if (!target) { | |
| 159 | + return <EmptyProjectFilePicker />; | |
| 160 | + } | |
| 161 | + | |
| 162 | + return <OpenProjectFilePicker setOpen={props.setOpen} target={target} />; | |
| 163 | +} | |
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; | |
| 4 | + | |
| 5 | +describe("resolveCenteredFileLineScrollTop", () => { | |
| 6 | + it("centers an estimated virtualized line position", () => { | |
| 7 | + expect( | |
| 8 | + resolveCenteredFileLineScrollTop({ | |
| 9 | + scrollTop: 0, | |
| 10 | + scrollHeight: 2_000, | |
| 11 | + viewportTop: 100, | |
| 12 | + viewportHeight: 400, | |
| 13 | + fileTop: 20, | |
| 14 | + estimatedLine: { top: 1_000, height: 20 }, | |
| 15 | + }), | |
| 16 | + ).toBe(830); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("corrects a stale estimate from the rendered line geometry", () => { | |
| 20 | + expect( | |
| 21 | + resolveCenteredFileLineScrollTop({ | |
| 22 | + scrollTop: 830, | |
| 23 | + scrollHeight: 2_000, | |
| 24 | + viewportTop: 100, | |
| 25 | + viewportHeight: 400, | |
| 26 | + fileTop: 20, | |
| 27 | + estimatedLine: { top: 1_000, height: 20 }, | |
| 28 | + renderedLine: { top: 620, height: 20 }, | |
| 29 | + }), | |
| 30 | + ).toBe(1_160); | |
| 31 | + }); | |
| 32 | +}); | |
| @@ -0,0 +1,24 @@ | ||
| 1 | +interface LineGeometry { | |
| 2 | + readonly top: number; | |
| 3 | + readonly height: number; | |
| 4 | +} | |
| 5 | + | |
| 6 | +interface CenteredFileLineScrollInput { | |
| 7 | + readonly scrollTop: number; | |
| 8 | + readonly scrollHeight: number; | |
| 9 | + readonly viewportTop: number; | |
| 10 | + readonly viewportHeight: number; | |
| 11 | + readonly fileTop: number; | |
| 12 | + readonly estimatedLine: LineGeometry; | |
| 13 | + readonly renderedLine?: LineGeometry; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function resolveCenteredFileLineScrollTop(input: CenteredFileLineScrollInput): number { | |
| 17 | + const lineTop = | |
| 18 | + input.renderedLine === undefined | |
| 19 | + ? input.fileTop + input.estimatedLine.top | |
| 20 | + : input.scrollTop + input.renderedLine.top - input.viewportTop; | |
| 21 | + const lineHeight = input.renderedLine?.height ?? input.estimatedLine.height; | |
| 22 | + const centeredTop = Math.max(0, lineTop - Math.max(0, (input.viewportHeight - lineHeight) / 2)); | |
| 23 | + return Math.min(centeredTop, Math.max(0, input.scrollHeight - input.viewportHeight)); | |
| 24 | +} | |
| @@ -11,6 +11,7 @@ import { useCallback } from "react"; · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 13 | 13 | import { projectEnvironment } from "~/state/projects"; |
| 14 | +import { useProjectPathSearch } from "~/state/queries"; | |
| 14 | 15 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 15 | 16 | |
| 16 | 17 | const EMPTY_PROJECT_FILE_PATH = ""; |
| @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( · 119 unmodified lines | ||
| 136 | 137 | }; |
| 137 | 138 | } |
| 138 | 139 | |
| 140 | +/** | |
| 141 | + * Backing query for the project file picker: a debounced, bounded, file-only | |
| 142 | + * server search. An empty query is a valid request — the index answers it | |
| 143 | + * with frecency-ordered files, so the picker's initial view is recent files | |
| 144 | + * without transferring the full workspace listing. `matchedQuery` is the | |
| 145 | + * query the returned entries were computed for, so the caller can highlight | |
| 146 | + * against results instead of half-typed input. | |
| 147 | + */ | |
| 148 | +export function useProjectFilePickerQuery( | |
| 149 | + environmentId: EnvironmentId, | |
| 150 | + cwd: string, | |
| 151 | + query: string, | |
| 152 | + limit: number, | |
| 153 | +) { | |
| 154 | + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { | |
| 155 | + allowEmptyQuery: true, | |
| 156 | + }); | |
| 157 | + | |
| 158 | + return { | |
| 159 | + entries: search.isPending ? [] : search.entries, | |
| 160 | + error: search.error, | |
| 161 | + isPending: search.isPending, | |
| 162 | + matchedQuery: search.searchedQuery, | |
| 163 | + }; | |
| 164 | +} | |
| 165 | + | |
| 139 | 166 | export function useProjectFileQuery( |
| 140 | 167 | environmentId: EnvironmentId, |
| 141 | 168 | cwd: string, |
| @@ -0,0 +1,183 @@ | ||
| 1 | +import { getFiletypeFromFileName } from "@pierre/diffs"; | |
| 2 | +import type { ProjectContentMatch } from "@t3tools/contracts"; | |
| 3 | +import { memo, Suspense, use, useMemo, type CSSProperties } from "react"; | |
| 4 | + | |
| 5 | +import { resolveDiffThemeName } from "~/lib/diffRendering"; | |
| 6 | +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; | |
| 7 | + | |
| 8 | +import { RenderErrorBoundary } from "../RenderErrorBoundary"; | |
| 9 | + | |
| 10 | +interface Range { | |
| 11 | + readonly start: number; | |
| 12 | + readonly end: number; | |
| 13 | +} | |
| 14 | + | |
| 15 | +interface CodeToken { | |
| 16 | + readonly content: string; | |
| 17 | + readonly offset: number; | |
| 18 | + readonly color?: string; | |
| 19 | + readonly fontStyle?: number; | |
| 20 | +} | |
| 21 | + | |
| 22 | +interface Segment { | |
| 23 | + readonly content: string; | |
| 24 | + readonly isMatch: boolean; | |
| 25 | + readonly start: number; | |
| 26 | + readonly end: number; | |
| 27 | + readonly token: CodeToken; | |
| 28 | +} | |
| 29 | + | |
| 30 | +function normalizeRanges(match: ProjectContentMatch): Range[] { | |
| 31 | + const ranges = match.matchRanges | |
| 32 | + .map((range) => ({ | |
| 33 | + start: Math.max(0, Math.min(match.lineContent.length, range.start)), | |
| 34 | + end: Math.max(0, Math.min(match.lineContent.length, range.end)), | |
| 35 | + })) | |
| 36 | + .filter((range) => range.end > range.start) | |
| 37 | + .toSorted((left, right) => left.start - right.start); | |
| 38 | + | |
| 39 | + const merged: Array<{ start: number; end: number }> = []; | |
| 40 | + for (const range of ranges) { | |
| 41 | + const previous = merged.at(-1); | |
| 42 | + if (previous && range.start <= previous.end) { | |
| 43 | + previous.end = Math.max(previous.end, range.end); | |
| 44 | + } else { | |
| 45 | + merged.push({ ...range }); | |
| 46 | + } | |
| 47 | + } | |
| 48 | + return merged; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function splitToken(line: string, token: CodeToken, ranges: ReadonlyArray<Range>): Segment[] { | |
| 52 | + const segments: Segment[] = []; | |
| 53 | + const tokenEnd = token.offset + token.content.length; | |
| 54 | + let cursor = token.offset; | |
| 55 | + | |
| 56 | + for (const range of ranges) { | |
| 57 | + if (range.end <= cursor) continue; | |
| 58 | + if (range.start >= tokenEnd) break; | |
| 59 | + | |
| 60 | + const matchStart = Math.max(cursor, range.start); | |
| 61 | + if (matchStart > cursor) { | |
| 62 | + segments.push({ | |
| 63 | + content: line.slice(cursor, matchStart), | |
| 64 | + isMatch: false, | |
| 65 | + start: cursor, | |
| 66 | + end: matchStart, | |
| 67 | + token, | |
| 68 | + }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + const matchEnd = Math.min(tokenEnd, range.end); | |
| 72 | + segments.push({ | |
| 73 | + content: line.slice(matchStart, matchEnd), | |
| 74 | + isMatch: true, | |
| 75 | + start: matchStart, | |
| 76 | + end: matchEnd, | |
| 77 | + token, | |
| 78 | + }); | |
| 79 | + cursor = matchEnd; | |
| 80 | + } | |
| 81 | + | |
| 82 | + if (cursor < tokenEnd) { | |
| 83 | + segments.push({ | |
| 84 | + content: line.slice(cursor, tokenEnd), | |
| 85 | + isMatch: false, | |
| 86 | + start: cursor, | |
| 87 | + end: tokenEnd, | |
| 88 | + token, | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + return segments; | |
| 92 | +} | |
| 93 | + | |
| 94 | +function tokenStyle(token: CodeToken): CSSProperties { | |
| 95 | + const fontStyle = token.fontStyle ?? 0; | |
| 96 | + return { | |
| 97 | + ...(token.color ? { color: token.color } : {}), | |
| 98 | + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), | |
| 99 | + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), | |
| 100 | + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), | |
| 101 | + }; | |
| 102 | +} | |
| 103 | + | |
| 104 | +function HighlightedTokens(props: { | |
| 105 | + readonly line: string; | |
| 106 | + readonly ranges: ReadonlyArray<Range>; | |
| 107 | + readonly tokens: ReadonlyArray<CodeToken>; | |
| 108 | +}) { | |
| 109 | + return props.tokens | |
| 110 | + .flatMap((token) => splitToken(props.line, token, props.ranges)) | |
| 111 | + .map((segment) => | |
| 112 | + segment.isMatch ? ( | |
| 113 | + <mark | |
| 114 | + className="rounded-[2px] bg-primary/25 text-inherit" | |
| 115 | + key={`${segment.start}:${segment.end}:match`} | |
| 116 | + style={tokenStyle(segment.token)} | |
| 117 | + > | |
| 118 | + {segment.content} | |
| 119 | + </mark> | |
| 120 | + ) : ( | |
| 121 | + <span key={`${segment.start}:${segment.end}:code`} style={tokenStyle(segment.token)}> | |
| 122 | + {segment.content} | |
| 123 | + </span> | |
| 124 | + ), | |
| 125 | + ); | |
| 126 | +} | |
| 127 | + | |
| 128 | +function SyntaxHighlightedTokens(props: { | |
| 129 | + readonly line: string; | |
| 130 | + readonly language: string; | |
| 131 | + readonly ranges: ReadonlyArray<Range>; | |
| 132 | + readonly theme: "light" | "dark"; | |
| 133 | +}) { | |
| 134 | + const highlighter = use(getSyntaxHighlighterPromise(props.language)); | |
| 135 | + const tokens = useMemo(() => { | |
| 136 | + try { | |
| 137 | + return highlighter.codeToTokens(props.line, { | |
| 138 | + lang: props.language, | |
| 139 | + theme: resolveDiffThemeName(props.theme), | |
| 140 | + }).tokens[0]; | |
| 141 | + } catch { | |
| 142 | + return undefined; | |
| 143 | + } | |
| 144 | + }, [highlighter, props.language, props.line, props.theme]); | |
| 145 | + | |
| 146 | + return tokens ? ( | |
| 147 | + <HighlightedTokens line={props.line} ranges={props.ranges} tokens={tokens} /> | |
| 148 | + ) : ( | |
| 149 | + <HighlightedTokens | |
| 150 | + line={props.line} | |
| 151 | + ranges={props.ranges} | |
| 152 | + tokens={[{ content: props.line, offset: 0 }]} | |
| 153 | + /> | |
| 154 | + ); | |
| 155 | +} | |
| 156 | + | |
| 157 | +export const HighlightedSearchLine = memo(function HighlightedSearchLine(props: { | |
| 158 | + readonly match: ProjectContentMatch; | |
| 159 | + readonly path: string; | |
| 160 | + readonly theme: "light" | "dark"; | |
| 161 | +}) { | |
| 162 | + const ranges = useMemo(() => normalizeRanges(props.match), [props.match]); | |
| 163 | + const fallback = ( | |
| 164 | + <HighlightedTokens | |
| 165 | + line={props.match.lineContent} | |
| 166 | + ranges={ranges} | |
| 167 | + tokens={[{ content: props.match.lineContent, offset: 0 }]} | |
| 168 | + /> | |
| 169 | + ); | |
| 170 | + | |
| 171 | + return ( | |
| 172 | + <RenderErrorBoundary fallback={fallback}> | |
| 173 | + <Suspense fallback={fallback}> | |
| 174 | + <SyntaxHighlightedTokens | |
| 175 | + line={props.match.lineContent} | |
| 176 | + language={getFiletypeFromFileName(props.path)} | |
| 177 | + ranges={ranges} | |
| 178 | + theme={props.theme} | |
| 179 | + /> | |
| 180 | + </Suspense> | |
| 181 | + </RenderErrorBoundary> | |
| 182 | + ); | |
| 183 | +}); | |
| @@ -0,0 +1,315 @@ | ||
| 1 | +import type { ProjectContentMatch } from "@t3tools/contracts"; | |
| 2 | +import { LoaderCircle } from "lucide-react"; | |
| 3 | +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 4 | + | |
| 5 | +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; | |
| 6 | +import { useTheme } from "~/hooks/useTheme"; | |
| 7 | +import { cn } from "~/lib/utils"; | |
| 8 | +import { useRightPanelStore } from "~/rightPanelStore"; | |
| 9 | +import { useProjectContentSearch } from "~/state/queries"; | |
| 10 | + | |
| 11 | +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; | |
| 12 | +import { CommandPaletteContent } from "../CommandPaletteContent"; | |
| 13 | +import { ScrollArea } from "../ui/scroll-area"; | |
| 14 | +import { HighlightedSearchLine } from "./HighlightedSearchLine"; | |
| 15 | + | |
| 16 | +interface ProjectContentSearchDialogProps { | |
| 17 | + readonly onOpenChange: (open: boolean) => void; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Result rows are syntax highlighted, so mounting all 500 possible rows at | |
| 22 | + * once stalls the UI. Rows render in windows that grow as the sentinel at the | |
| 23 | + * bottom of the list scrolls into view (or keyboard navigation moves past | |
| 24 | + * the rendered window). | |
| 25 | + */ | |
| 26 | +const VISIBLE_MATCH_WINDOW = 100; | |
| 27 | + | |
| 28 | +interface MatchGroup { | |
| 29 | + readonly path: string; | |
| 30 | + readonly matches: ReadonlyArray<ProjectContentMatch & { readonly resultIndex: number }>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function splitPath(path: string): { readonly name: string; readonly directory: string } { | |
| 34 | + const separator = path.lastIndexOf("/"); | |
| 35 | + return separator === -1 | |
| 36 | + ? { name: path, directory: "" } | |
| 37 | + : { name: path.slice(separator + 1), directory: path.slice(0, separator) }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] { | |
| 41 | + const groups = new Map<string, Array<ProjectContentMatch & { readonly resultIndex: number }>>(); | |
| 42 | + matches.forEach((match, resultIndex) => { | |
| 43 | + const group = groups.get(match.path); | |
| 44 | + const indexedMatch = { ...match, resultIndex }; | |
| 45 | + if (group) { | |
| 46 | + group.push(indexedMatch); | |
| 47 | + } else { | |
| 48 | + groups.set(match.path, [indexedMatch]); | |
| 49 | + } | |
| 50 | + }); | |
| 51 | + return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches })); | |
| 52 | +} | |
| 53 | + | |
| 54 | +function SearchOptionButton(props: { | |
| 55 | + readonly active: boolean; | |
| 56 | + readonly label: string; | |
| 57 | + readonly onClick: () => void; | |
| 58 | + readonly children: ReactNode; | |
| 59 | +}) { | |
| 60 | + return ( | |
| 61 | + <button | |
| 62 | + type="button" | |
| 63 | + aria-label={props.label} | |
| 64 | + aria-pressed={props.active} | |
| 65 | + title={props.label} | |
| 66 | + className={cn( | |
| 67 | + "flex size-8 shrink-0 items-center justify-center rounded-[5px] font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground", | |
| 68 | + props.active && "bg-accent text-foreground shadow-sm", | |
| 69 | + )} | |
| 70 | + onClick={props.onClick} | |
| 71 | + > | |
| 72 | + {props.children} | |
| 73 | + </button> | |
| 74 | + ); | |
| 75 | +} | |
| 76 | + | |
| 77 | +function EmptyContentSearchDialog() { | |
| 78 | + return ( | |
| 79 | + <CommandPaletteContent | |
| 80 | + aria-label="Search project contents" | |
| 81 | + escapeLabel="Back" | |
| 82 | + footerActionLabel="Open file" | |
| 83 | + inputProps={{ disabled: true, placeholder: "Search project contents…" }} | |
| 84 | + mode="none" | |
| 85 | + panelClassName="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground" | |
| 86 | + testId="project-content-search" | |
| 87 | + value="" | |
| 88 | + > | |
| 89 | + Open a project to search its files. | |
| 90 | + </CommandPaletteContent> | |
| 91 | + ); | |
| 92 | +} | |
| 93 | + | |
| 94 | +function OpenContentSearchDialog(props: { | |
| 95 | + readonly onOpenChange: (open: boolean) => void; | |
| 96 | + readonly target: ActiveProjectTarget; | |
| 97 | +}) { | |
| 98 | + const { target } = props; | |
| 99 | + const { resolvedTheme } = useTheme(); | |
| 100 | + const [query, setQuery] = useState(""); | |
| 101 | + const [caseSensitive, setCaseSensitive] = useState(false); | |
| 102 | + const [wholeWord, setWholeWord] = useState(false); | |
| 103 | + const [useRegex, setUseRegex] = useState(false); | |
| 104 | + const [selectedIndex, setSelectedIndex] = useState(0); | |
| 105 | + | |
| 106 | + const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW); | |
| 107 | + | |
| 108 | + const search = useProjectContentSearch({ | |
| 109 | + environmentId: target.environmentId, | |
| 110 | + cwd: target.cwd, | |
| 111 | + query, | |
| 112 | + caseSensitive, | |
| 113 | + wholeWord, | |
| 114 | + useRegex, | |
| 115 | + }); | |
| 116 | + const canOpenMatches = !search.isPending; | |
| 117 | + const matches = search.matches; | |
| 118 | + const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); | |
| 119 | + const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); | |
| 120 | + | |
| 121 | + useEffect(() => { | |
| 122 | + setSelectedIndex(0); | |
| 123 | + setVisibleCount(VISIBLE_MATCH_WINDOW); | |
| 124 | + }, [matches]); | |
| 125 | + | |
| 126 | + useEffect(() => { | |
| 127 | + if (selectedIndex >= visibleCount) { | |
| 128 | + setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW); | |
| 129 | + return; | |
| 130 | + } | |
| 131 | + document | |
| 132 | + .querySelector<HTMLElement>(`[data-content-search-result="${selectedIndex}"]`) | |
| 133 | + ?.scrollIntoView({ block: "nearest" }); | |
| 134 | + }, [selectedIndex, visibleCount]); | |
| 135 | + | |
| 136 | + const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => { | |
| 137 | + if (!sentinel) return; | |
| 138 | + const observer = new IntersectionObserver((entries) => { | |
| 139 | + if (entries.some((entry) => entry.isIntersecting)) { | |
| 140 | + setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW); | |
| 141 | + } | |
| 142 | + }); | |
| 143 | + observer.observe(sentinel); | |
| 144 | + return () => observer.disconnect(); | |
| 145 | + }, []); | |
| 146 | + | |
| 147 | + const openMatch = (match: ProjectContentMatch) => { | |
| 148 | + if (!canOpenMatches) return; | |
| 149 | + props.onOpenChange(false); | |
| 150 | + useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); | |
| 151 | + }; | |
| 152 | + const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); | |
| 153 | + const showSearchStatus = | |
| 154 | + search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; | |
| 155 | + | |
| 156 | + return ( | |
| 157 | + <CommandPaletteContent | |
| 158 | + aria-label={`Search file contents in ${target.projectName}`} | |
| 159 | + escapeLabel="Back" | |
| 160 | + footerActionLabel="Open file" | |
| 161 | + inputAccessory={ | |
| 162 | + <div className="absolute inset-e-2.5 top-1/2 flex shrink-0 -translate-y-1/2 items-center gap-0.5 rounded-md border bg-muted/30 p-0.5"> | |
| 163 | + <SearchOptionButton | |
| 164 | + active={caseSensitive} | |
| 165 | + label="Match case" | |
| 166 | + onClick={() => setCaseSensitive((current) => !current)} | |
| 167 | + > | |
| 168 | + Aa | |
| 169 | + </SearchOptionButton> | |
| 170 | + <SearchOptionButton | |
| 171 | + active={wholeWord} | |
| 172 | + label="Match whole word" | |
| 173 | + onClick={() => setWholeWord((current) => !current)} | |
| 174 | + > | |
| 175 | + <span className="underline decoration-2 underline-offset-2">ab</span> | |
| 176 | + </SearchOptionButton> | |
| 177 | + <SearchOptionButton | |
| 178 | + active={useRegex} | |
| 179 | + label="Use regular expression" | |
| 180 | + onClick={() => setUseRegex((current) => !current)} | |
| 181 | + > | |
| 182 | + .* | |
| 183 | + </SearchOptionButton> | |
| 184 | + </div> | |
| 185 | + } | |
| 186 | + inputProps={{ | |
| 187 | + className: "pe-30", | |
| 188 | + placeholder: `Search in ${target.projectName}`, | |
| 189 | + onKeyDown: (event) => { | |
| 190 | + if (event.key === "ArrowDown" && matches.length > 0) { | |
| 191 | + event.preventDefault(); | |
| 192 | + setSelectedIndex((current) => (current + 1) % matches.length); | |
| 193 | + } else if (event.key === "ArrowUp" && matches.length > 0) { | |
| 194 | + event.preventDefault(); | |
| 195 | + setSelectedIndex((current) => (current - 1 + matches.length) % matches.length); | |
| 196 | + } else if (event.key === "Enter") { | |
| 197 | + // While a newer query is debouncing or in flight, the visible | |
| 198 | + // matches belong to the previous query; opening one would jump | |
| 199 | + // to a result the user did not ask for. | |
| 200 | + if (!canOpenMatches) { | |
| 201 | + event.preventDefault(); | |
| 202 | + return; | |
| 203 | + } | |
| 204 | + const match = matches[selectedIndex]; | |
| 205 | + if (match) { | |
| 206 | + event.preventDefault(); | |
| 207 | + openMatch(match); | |
| 208 | + } | |
| 209 | + } | |
| 210 | + }, | |
| 211 | + }} | |
| 212 | + mode="none" | |
| 213 | + onValueChange={setQuery} | |
| 214 | + panelClassName="flex min-h-0 flex-1 flex-col" | |
| 215 | + testId="project-content-search" | |
| 216 | + value={query} | |
| 217 | + > | |
| 218 | + {showSearchStatus ? ( | |
| 219 | + <div className="flex h-9 shrink-0 items-center border-b px-3 text-xs text-muted-foreground"> | |
| 220 | + {search.isPending ? ( | |
| 221 | + <span className="flex items-center gap-2"> | |
| 222 | + <LoaderCircle className="size-3.5 animate-spin" /> Searching… | |
| 223 | + </span> | |
| 224 | + ) : search.error ? ( | |
| 225 | + <span className="text-destructive">{search.error}</span> | |
| 226 | + ) : search.invalidRegex ? ( | |
| 227 | + <span className="text-destructive">Invalid regular expression</span> | |
| 228 | + ) : ( | |
| 229 | + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` | |
| 230 | + )} | |
| 231 | + </div> | |
| 232 | + ) : null} | |
| 233 | + | |
| 234 | + {matches.length === 0 ? ( | |
| 235 | + <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground"> | |
| 236 | + {search.hasQuery && !search.isPending && !search.error | |
| 237 | + ? "No results found." | |
| 238 | + : "Type to search across your project."} | |
| 239 | + </div> | |
| 240 | + ) : ( | |
| 241 | + <ScrollArea className="min-h-0 flex-1" scrollFade> | |
| 242 | + <div className="py-2"> | |
| 243 | + {groups.map((group) => { | |
| 244 | + const path = splitPath(group.path); | |
| 245 | + return ( | |
| 246 | + <section className="pb-2" key={group.path}> | |
| 247 | + <div className="sticky top-0 z-10 flex h-8 items-center gap-2 bg-popover/95 px-3 text-xs backdrop-blur-sm"> | |
| 248 | + <PierreEntryIcon | |
| 249 | + pathValue={group.path} | |
| 250 | + kind="file" | |
| 251 | + theme={resolvedTheme} | |
| 252 | + className="size-3.5" | |
| 253 | + /> | |
| 254 | + <span className="font-medium text-foreground">{path.name}</span> | |
| 255 | + {path.directory ? ( | |
| 256 | + <span className="min-w-0 truncate text-muted-foreground"> | |
| 257 | + {path.directory} | |
| 258 | + </span> | |
| 259 | + ) : null} | |
| 260 | + <span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 tabular-nums text-[10px] text-muted-foreground"> | |
| 261 | + {group.matches.length} | |
| 262 | + </span> | |
| 263 | + </div> | |
| 264 | + {group.matches.map((match) => ( | |
| 265 | + <button | |
| 266 | + type="button" | |
| 267 | + key={`${match.path}:${match.lineNumber}:${match.resultIndex}`} | |
| 268 | + data-content-search-result={match.resultIndex} | |
| 269 | + className={cn( | |
| 270 | + "flex h-7 w-full min-w-0 items-center gap-3 px-3 text-left font-mono text-xs hover:bg-accent/60 disabled:pointer-events-none", | |
| 271 | + match.resultIndex === selectedIndex && "bg-accent text-accent-foreground", | |
| 272 | + )} | |
| 273 | + disabled={!canOpenMatches} | |
| 274 | + onMouseEnter={() => setSelectedIndex(match.resultIndex)} | |
| 275 | + onClick={() => openMatch(match)} | |
| 276 | + > | |
| 277 | + <span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground/70"> | |
| 278 | + {match.lineNumber} | |
| 279 | + </span> | |
| 280 | + <span className="min-w-0 flex-1 truncate whitespace-pre"> | |
| 281 | + <HighlightedSearchLine | |
| 282 | + match={match} | |
| 283 | + path={group.path} | |
| 284 | + theme={resolvedTheme} | |
| 285 | + /> | |
| 286 | + </span> | |
| 287 | + </button> | |
| 288 | + ))} | |
| 289 | + </section> | |
| 290 | + ); | |
| 291 | + })} | |
| 292 | + {matches.length > visibleCount ? ( | |
| 293 | + <div ref={observeLoadMoreSentinel} className="h-8" aria-hidden="true" /> | |
| 294 | + ) : null} | |
| 295 | + </div> | |
| 296 | + </ScrollArea> | |
| 297 | + )} | |
| 298 | + </CommandPaletteContent> | |
| 299 | + ); | |
| 300 | +} | |
| 301 | + | |
| 302 | +export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { | |
| 303 | + const target = useActiveProjectTarget(); | |
| 304 | + | |
| 305 | + return target ? ( | |
| 306 | + <OpenContentSearchDialog | |
| 307 | + // Reset the query and options whenever the workspace changes. | |
| 308 | + key={`${target.environmentId}:${target.cwd}`} | |
| 309 | + onOpenChange={props.onOpenChange} | |
| 310 | + target={target} | |
| 311 | + /> | |
| 312 | + ) : ( | |
| 313 | + <EmptyContentSearchDialog /> | |
| 314 | + ); | |
| 315 | +} | |
| @@ -0,0 +1,41 @@ | ||
| 1 | +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { useProjects } from "~/state/entities"; | |
| 5 | + | |
| 6 | +import { useHandleNewThread } from "./useHandleNewThread"; | |
| 7 | + | |
| 8 | +export interface ActiveProjectTarget { | |
| 9 | + readonly environmentId: EnvironmentId; | |
| 10 | + readonly cwd: string; | |
| 11 | + readonly projectName: string; | |
| 12 | + readonly threadRef: ScopedThreadRef; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Resolves the project workspace behind the active thread (or draft) so | |
| 17 | + * project-scoped surfaces like the file picker and content search know which | |
| 18 | + * workspace to query and which thread's right panel opens their results. | |
| 19 | + */ | |
| 20 | +export function useActiveProjectTarget(): ActiveProjectTarget | null { | |
| 21 | + const { activeDraftThread, activeThread } = useHandleNewThread(); | |
| 22 | + const projects = useProjects(); | |
| 23 | + const thread = activeThread ?? activeDraftThread; | |
| 24 | + const threadId = activeThread?.id ?? activeDraftThread?.threadId; | |
| 25 | + const project = thread | |
| 26 | + ? projects.find( | |
| 27 | + (candidate) => | |
| 28 | + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, | |
| 29 | + ) | |
| 30 | + : null; | |
| 31 | + const cwd = thread?.worktreePath ?? project?.workspaceRoot; | |
| 32 | + | |
| 33 | + if (!thread || !threadId || !project || !cwd) return null; | |
| 34 | + | |
| 35 | + return { | |
| 36 | + environmentId: project.environmentId, | |
| 37 | + cwd, | |
| 38 | + projectName: project.title, | |
| 39 | + threadRef: scopeThreadRef(thread.environmentId, threadId), | |
| 40 | + }; | |
| 41 | +} | |
| @@ -190,7 +190,7 @@ export function useNewThreadHandler() { · 189 unmodified lines | ||
| 190 | 190 | reusableStoredDraftThread.draftId, |
| 191 | 191 | { |
| 192 | 192 | threadId: reusableStoredDraftThread.threadId, |
| 193 | − ...(workspaceContext ?? {}), | |
| 193 | + ...workspaceContext, | |
| 194 | 194 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 195 | 195 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 196 | 196 | }, |
| @@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ · 117 unmodified lines | ||
| 118 | 118 | command: "commandPalette.toggle", |
| 119 | 119 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 120 | 120 | }, |
| 121 | + { | |
| 122 | + shortcut: modShortcut("p"), | |
| 123 | + command: "filePicker.toggle", | |
| 124 | + whenAst: whenNot(whenIdentifier("terminalFocus")), | |
| 125 | + }, | |
| 126 | + { | |
| 127 | + shortcut: modShortcut("f", { shiftKey: true }), | |
| 128 | + command: "projectSearch.toggle", | |
| 129 | + whenAst: whenNot(whenIdentifier("terminalFocus")), | |
| 130 | + }, | |
| 121 | 131 | { |
| 122 | 132 | shortcut: modShortcut("m", { shiftKey: true }), |
| 123 | 133 | command: "modelPicker.toggle", |
| @@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => { · 203 unmodified lines | ||
| 327 | 337 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), |
| 328 | 338 | "⌘K", |
| 329 | 339 | ); |
| 340 | + assert.strictEqual( | |
| 341 | + shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), | |
| 342 | + "⌘P", | |
| 343 | + ); | |
| 344 | + assert.strictEqual( | |
| 345 | + shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"), | |
| 346 | + "⇧⌘F", | |
| 347 | + ); | |
| 330 | 348 | assert.strictEqual( |
| 331 | 349 | shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), |
| 332 | 350 | "Ctrl+Shift+M", |
| @@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => { · 181 unmodified lines | ||
| 514 | 532 | ); |
| 515 | 533 | }); |
| 516 | 534 | |
| 535 | + it("matches filePicker.toggle shortcut outside terminal focus", () => { | |
| 536 | + assert.strictEqual( | |
| 537 | + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { | |
| 538 | + platform: "MacIntel", | |
| 539 | + context: { terminalFocus: false }, | |
| 540 | + }), | |
| 541 | + "filePicker.toggle", | |
| 542 | + ); | |
| 543 | + assert.notStrictEqual( | |
| 544 | + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { | |
| 545 | + platform: "MacIntel", | |
| 546 | + context: { terminalFocus: true }, | |
| 547 | + }), | |
| 548 | + "filePicker.toggle", | |
| 549 | + ); | |
| 550 | + }); | |
| 551 | + | |
| 552 | + it("matches projectSearch.toggle shortcut outside terminal focus", () => { | |
| 553 | + assert.strictEqual( | |
| 554 | + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 555 | + platform: "MacIntel", | |
| 556 | + context: { terminalFocus: false }, | |
| 557 | + }), | |
| 558 | + "projectSearch.toggle", | |
| 559 | + ); | |
| 560 | + assert.notStrictEqual( | |
| 561 | + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 562 | + platform: "MacIntel", | |
| 563 | + context: { terminalFocus: true }, | |
| 564 | + }), | |
| 565 | + "projectSearch.toggle", | |
| 566 | + ); | |
| 567 | + }); | |
| 568 | + | |
| 517 | 569 | it("matches diff.toggle shortcut outside terminal focus", () => { |
| 518 | 570 | assert.isTrue( |
| 519 | 571 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
| @@ -0,0 +1,28 @@ | ||
| 1 | +import type { DiffsHighlighter } from "@pierre/diffs"; | |
| 2 | +import { expect, it, vi } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +const { getSharedHighlighter } = vi.hoisted(() => ({ | |
| 5 | + getSharedHighlighter: vi.fn(), | |
| 6 | +})); | |
| 7 | + | |
| 8 | +vi.mock("@pierre/diffs", () => ({ | |
| 9 | + getSharedHighlighter, | |
| 10 | +})); | |
| 11 | + | |
| 12 | +import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; | |
| 13 | + | |
| 14 | +it("caches the recovered text highlighter for unsupported languages", async () => { | |
| 15 | + const textHighlighter = {} as DiffsHighlighter; | |
| 16 | + getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) => | |
| 17 | + langs[0] === "text" | |
| 18 | + ? Promise.resolve(textHighlighter) | |
| 19 | + : Promise.reject(new Error("unsupported language")), | |
| 20 | + ); | |
| 21 | + | |
| 22 | + const first = getSyntaxHighlighterPromise("unsupported-test-language"); | |
| 23 | + await expect(first).resolves.toBe(textHighlighter); | |
| 24 | + const second = getSyntaxHighlighterPromise("unsupported-test-language"); | |
| 25 | + | |
| 26 | + expect(second).toBe(first); | |
| 27 | + expect(getSharedHighlighter).toHaveBeenCalledTimes(2); | |
| 28 | +}); | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { | |
| 2 | + getSharedHighlighter, | |
| 3 | + type DiffsHighlighter, | |
| 4 | + type SupportedLanguages, | |
| 5 | +} from "@pierre/diffs"; | |
| 6 | + | |
| 7 | +import { resolveDiffThemeName } from "./diffRendering"; | |
| 8 | + | |
| 9 | +const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>(); | |
| 10 | + | |
| 11 | +export function getSyntaxHighlighterPromise(language: string): Promise<DiffsHighlighter> { | |
| 12 | + const cached = highlighterPromiseCache.get(language); | |
| 13 | + if (cached) return cached; | |
| 14 | + | |
| 15 | + const promise = getSharedHighlighter({ | |
| 16 | + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], | |
| 17 | + langs: [language as SupportedLanguages], | |
| 18 | + preferredHighlighter: "shiki-js", | |
| 19 | + }).catch((error) => { | |
| 20 | + if (language === "text") { | |
| 21 | + highlighterPromiseCache.delete(language); | |
| 22 | + // "text" itself failed — Shiki cannot initialize at all, surface the error | |
| 23 | + throw error; | |
| 24 | + } | |
| 25 | + // Language not supported by Shiki — fall back to "text" | |
| 26 | + return getSyntaxHighlighterPromise("text"); | |
| 27 | + }); | |
| 28 | + highlighterPromiseCache.set(language, promise); | |
| 29 | + return promise; | |
| 30 | +} | |
| @@ -1,11 +1,24 @@ | ||
| 1 | 1 | import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; |
| 2 | 2 | import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; |
| 3 | +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; | |
| 4 | +import { WS_METHODS } from "@t3tools/contracts"; | |
| 3 | 5 | |
| 4 | 6 | import { environmentCatalog } from "../connection/catalog"; |
| 5 | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 6 | 8 | import { environmentSnapshotAtom } from "./shell"; |
| 7 | 9 | |
| 8 | 10 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); |
| 11 | +/** | |
| 12 | + * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile | |
| 13 | + * surface, so the atom family lives here instead of the shared client-runtime | |
| 14 | + * project atoms consumed by the mobile app. | |
| 15 | + */ | |
| 16 | +export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { | |
| 17 | + label: "environment-data:projects:search-contents", | |
| 18 | + tag: WS_METHODS.projectsSearchContents, | |
| 19 | + staleTimeMs: 5_000, | |
| 20 | + idleTtlMs: 60_000, | |
| 21 | +}); | |
| 9 | 22 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 10 | 23 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 11 | 24 | snapshotAtom: environmentSnapshotAtom, |
| @@ -0,0 +1,25 @@ | ||
| 1 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import { areProjectPathSearchTargetsEqual } from "./queries"; | |
| 5 | + | |
| 6 | +describe("areProjectPathSearchTargetsEqual", () => { | |
| 7 | + const target = { | |
| 8 | + environmentId: EnvironmentId.make("environment-a"), | |
| 9 | + cwd: "/project-a", | |
| 10 | + query: "index", | |
| 11 | + }; | |
| 12 | + | |
| 13 | + it("requires the environment, workspace, query, and entry kind to match", () => { | |
| 14 | + expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true); | |
| 15 | + expect( | |
| 16 | + areProjectPathSearchTargetsEqual(target, { | |
| 17 | + ...target, | |
| 18 | + environmentId: EnvironmentId.make("environment-b"), | |
| 19 | + }), | |
| 20 | + ).toBe(false); | |
| 21 | + expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false); | |
| 22 | + expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false); | |
| 23 | + expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false); | |
| 24 | + }); | |
| 25 | +}); | |
| @@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; · 11 unmodified lines | ||
| 12 | 12 | import type { |
| 13 | 13 | EnvironmentId, |
| 14 | 14 | OrchestrationThread, |
| 15 | + ProjectContentMatch, | |
| 16 | + ProjectEntryKind, | |
| 15 | 17 | ThreadId, |
| 16 | 18 | VcsListRefsResult, |
| 17 | 19 | VcsRef, |
| @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 6 unmodified lines | ||
| 24 | 26 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 25 | 27 | import { orchestrationEnvironment } from "./orchestration"; |
| 26 | 28 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; |
| 27 | −import { projectEnvironment } from "./projects"; | |
| 29 | +import { projectContentSearch, projectEnvironment } from "./projects"; | |
| 28 | 30 | import { useEnvironmentQuery } from "./query"; |
| 29 | 31 | import { useEnvironmentThread } from "./threads"; |
| 30 | 32 | import { vcsEnvironment } from "./vcs"; |
| 31 | 33 | |
| 32 | −const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | |
| 34 | +const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; | |
| 33 | 35 | const COMPOSER_PATH_SEARCH_LIMIT = 80; |
| 36 | +const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; | |
| 37 | +const PROJECT_CONTENT_SEARCH_LIMIT = 500; | |
| 34 | 38 | const THREAD_SEARCH_DEBOUNCE_MS = 200; |
| 35 | 39 | const VCS_REF_LIST_LIMIT = 100; |
| 36 | 40 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; |
| 41 | +const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = []; | |
| 37 | 42 | const INITIAL_BRANCH_CURSORS = [undefined] as const; |
| 38 | 43 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); |
| 39 | 44 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ |
| @@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { · 189 unmodified lines | ||
| 229 | 234 | }; |
| 230 | 235 | } |
| 231 | 236 | |
| 232 | −export function useComposerPathSearch(target: ComposerPathSearchTarget) { | |
| 237 | +type ProjectPathSearchTarget = ComposerPathSearchTarget & { | |
| 238 | + readonly kind?: ProjectEntryKind | undefined; | |
| 239 | +}; | |
| 240 | + | |
| 241 | +export function areProjectPathSearchTargetsEqual( | |
| 242 | + left: ProjectPathSearchTarget, | |
| 243 | + right: ProjectPathSearchTarget, | |
| 244 | +): boolean { | |
| 245 | + return ( | |
| 246 | + left.environmentId === right.environmentId && | |
| 247 | + left.cwd === right.cwd && | |
| 248 | + left.query === right.query && | |
| 249 | + left.kind === right.kind | |
| 250 | + ); | |
| 251 | +} | |
| 252 | + | |
| 253 | +export function useProjectPathSearch( | |
| 254 | + target: ProjectPathSearchTarget, | |
| 255 | + limit: number, | |
| 256 | + options?: { readonly allowEmptyQuery?: boolean }, | |
| 257 | +) { | |
| 258 | + const allowEmptyQuery = options?.allowEmptyQuery === true; | |
| 233 | 259 | const normalizedTarget = useMemo( |
| 234 | 260 | () => ({ |
| 235 | 261 | environmentId: target.environmentId, |
| 236 | 262 | cwd: target.cwd, |
| 237 | − query: target.query?.trim() ?? "", | |
| 263 | + query: target.query == null ? null : target.query.trim(), | |
| 264 | + kind: target.kind, | |
| 238 | 265 | }), |
| 239 | − [target.cwd, target.environmentId, target.query], | |
| 266 | + [target.cwd, target.environmentId, target.kind, target.query], | |
| 240 | 267 | ); |
| 241 | − const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); | |
| 268 | + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); | |
| 242 | 269 | const result = useEnvironmentQuery( |
| 243 | 270 | debouncedTarget.environmentId !== null && |
| 244 | 271 | debouncedTarget.cwd !== null && |
| 245 | − debouncedTarget.query.length > 0 | |
| 272 | + debouncedTarget.query !== null && | |
| 273 | + (allowEmptyQuery || debouncedTarget.query.length > 0) | |
| 246 | 274 | ? projectEnvironment.searchEntries({ |
| 247 | 275 | environmentId: debouncedTarget.environmentId, |
| 248 | 276 | input: { |
| 249 | 277 | cwd: debouncedTarget.cwd, |
| 250 | 278 | query: debouncedTarget.query, |
| 251 | − limit: COMPOSER_PATH_SEARCH_LIMIT, | |
| 279 | + limit, | |
| 280 | + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), | |
| 252 | 281 | }, |
| 253 | 282 | }) |
| 254 | 283 | : null, |
| @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { · 2 unmodified lines | ||
| 257 | 286 | return { |
| 258 | 287 | entries: result.data?.entries ?? [], |
| 259 | 288 | error: result.error, |
| 260 | − isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, | |
| 289 | + isPending: | |
| 290 | + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, | |
| 291 | + searchedQuery: debouncedTarget.query ?? "", | |
| 261 | 292 | refresh: result.refresh, |
| 262 | 293 | }; |
| 263 | 294 | } |
| 264 | 295 | |
| 296 | +export function useComposerPathSearch(target: ComposerPathSearchTarget) { | |
| 297 | + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); | |
| 298 | +} | |
| 299 | + | |
| 300 | +interface ProjectContentSearchTarget { | |
| 301 | + readonly environmentId: EnvironmentId | null; | |
| 302 | + readonly cwd: string | null; | |
| 303 | + readonly query: string; | |
| 304 | + readonly caseSensitive: boolean; | |
| 305 | + readonly wholeWord: boolean; | |
| 306 | + readonly useRegex: boolean; | |
| 307 | +} | |
| 308 | + | |
| 309 | +export function useProjectContentSearch(target: ProjectContentSearchTarget) { | |
| 310 | + // Whitespace is significant in content queries; trimming is only used to | |
| 311 | + // decide whether the input is blank. | |
| 312 | + const query = target.query; | |
| 313 | + const hasQuery = query.trim().length > 0; | |
| 314 | + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); | |
| 315 | + const result = useEnvironmentQuery( | |
| 316 | + target.environmentId !== null && | |
| 317 | + target.cwd !== null && | |
| 318 | + hasQuery && | |
| 319 | + debouncedQuery.trim().length > 0 | |
| 320 | + ? projectContentSearch({ | |
| 321 | + environmentId: target.environmentId, | |
| 322 | + input: { | |
| 323 | + cwd: target.cwd, | |
| 324 | + query: debouncedQuery, | |
| 325 | + limit: PROJECT_CONTENT_SEARCH_LIMIT, | |
| 326 | + caseSensitive: target.caseSensitive, | |
| 327 | + wholeWord: target.wholeWord, | |
| 328 | + useRegex: target.useRegex, | |
| 329 | + }, | |
| 330 | + }) | |
| 331 | + : null, | |
| 332 | + ); | |
| 333 | + | |
| 334 | + return { | |
| 335 | + matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, | |
| 336 | + error: result.error, | |
| 337 | + isPending: hasQuery && (query !== debouncedQuery || result.isPending), | |
| 338 | + hasQuery, | |
| 339 | + truncated: result.data?.truncated ?? false, | |
| 340 | + invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, | |
| 341 | + }; | |
| 342 | +} | |
| 343 | + | |
| 265 | 344 | export function useCheckpointDiff( |
| 266 | 345 | target: CheckpointDiffTarget, |
| 267 | 346 | options?: { readonly enabled?: boolean }, |
| @@ -37,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. · 36 unmodified lines | ||
| 37 | 37 | Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and |
| 38 | 38 | `chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`. |
| 39 | 39 | |
| 40 | +`filePicker.toggle` opens file search for the active project and defaults to `mod+p`. | |
| 41 | +`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. | |
| 42 | +Repeating either shortcut closes that search, and switching shortcuts replaces the open search. | |
| 43 | + | |
| 40 | 44 | The command palette searches active thread titles, projects, branches, user messages, and final |
| 41 | 45 | agent responses across connected environments. Message matches show one labeled excerpt while |
| 42 | 46 | keeping the thread's project, branch, and machine context visible. Message search begins after two |
| @@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () => · 58 unmodified lines | ||
| 59 | 59 | }); |
| 60 | 60 | assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); |
| 61 | 61 | |
| 62 | + const parsedFilePicker = yield* decode(KeybindingRule, { | |
| 63 | + key: "mod+p", | |
| 64 | + command: "filePicker.toggle", | |
| 65 | + }); | |
| 66 | + assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); | |
| 67 | + | |
| 68 | + const parsedProjectSearch = yield* decode(KeybindingRule, { | |
| 69 | + key: "mod+shift+f", | |
| 70 | + command: "projectSearch.toggle", | |
| 71 | + }); | |
| 72 | + assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); | |
| 73 | + | |
| 62 | 74 | const parsedLocal = yield* decode(KeybindingRule, { |
| 63 | 75 | key: "mod+shift+n", |
| 64 | 76 | command: "chat.newLocal", |
| @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ · 62 unmodified lines | ||
| 63 | 63 | "preview.zoomOut", |
| 64 | 64 | "preview.resetZoom", |
| 65 | 65 | "commandPalette.toggle", |
| 66 | + "filePicker.toggle", | |
| 67 | + "projectSearch.toggle", | |
| 66 | 68 | "composer.stash", |
| 67 | 69 | "chat.new", |
| 68 | 70 | "chat.newLocal", |
| @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ProjectReadFileError, |
| 6 | + ProjectSearchContentsError, | |
| 7 | + ProjectSearchContentsInput, | |
| 6 | 8 | ProjectSearchEntriesError, |
| 9 | + ProjectSearchEntriesInput, | |
| 7 | 10 | ProjectWriteFileError, |
| 8 | 11 | } from "./project.ts"; |
| 9 | 12 | |
| 13 | +const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); | |
| 14 | +const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); | |
| 15 | + | |
| 16 | +describe("project search inputs", () => { | |
| 17 | + it("allows an empty entries query for bounded frecency browsing", () => { | |
| 18 | + const decoded = decodeSearchEntriesInput({ | |
| 19 | + cwd: "/workspace", | |
| 20 | + query: " ", | |
| 21 | + limit: 10, | |
| 22 | + kind: "file", | |
| 23 | + }); | |
| 24 | + expect(decoded.query).toBe(""); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("preserves whitespace in content search queries", () => { | |
| 28 | + const decoded = decodeSearchContentsInput({ | |
| 29 | + cwd: "/workspace", | |
| 30 | + query: " foo ", | |
| 31 | + limit: 10, | |
| 32 | + caseSensitive: false, | |
| 33 | + wholeWord: false, | |
| 34 | + useRegex: false, | |
| 35 | + }); | |
| 36 | + expect(decoded.query).toBe(" foo "); | |
| 37 | + }); | |
| 38 | +}); | |
| 39 | + | |
| 10 | 40 | describe("project RPC errors", () => { |
| 11 | 41 | it("derives stable messages from structured request context while retaining causes", () => { |
| 12 | 42 | const cause = new Error("sensitive platform detail"); |
| @@ -39,6 +69,18 @@ describe("project RPC errors", () => { · 26 unmodified lines | ||
| 39 | 69 | expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); |
| 40 | 70 | expect(readError.message).not.toContain(cause.message); |
| 41 | 71 | expect(readError.cause).toBe(cause); |
| 72 | + | |
| 73 | + const contentSearchError = new ProjectSearchContentsError({ | |
| 74 | + cwd: "/workspace", | |
| 75 | + queryLength: "authorization: Bearer secret-token".length, | |
| 76 | + limit: 100, | |
| 77 | + failure: "search_index_search_failed", | |
| 78 | + cause, | |
| 79 | + }); | |
| 80 | + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); | |
| 81 | + expect(contentSearchError.message).not.toContain(cause.message); | |
| 82 | + expect(contentSearchError).not.toHaveProperty("query"); | |
| 83 | + expect(contentSearchError.cause).toBe(cause); | |
| 42 | 84 | }); |
| 43 | 85 | |
| 44 | 86 | it("decodes legacy message-only errors during rolling upgrades", () => { |
| @@ -1,19 +1,29 @@ | ||
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | −import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 2 | +import { | |
| 3 | + NonNegativeInt, | |
| 4 | + PositiveInt, | |
| 5 | + TrimmedNonEmptyString, | |
| 6 | + TrimmedString, | |
| 7 | +} from "./baseSchemas.ts"; | |
| 3 | 8 | |
| 4 | 9 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 10 | +const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; | |
| 5 | 11 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; |
| 6 | 12 | const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; |
| 7 | 13 | |
| 14 | +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); | |
| 15 | +export type ProjectEntryKind = typeof ProjectEntryKind.Type; | |
| 16 | + | |
| 8 | 17 | export const ProjectSearchEntriesInput = Schema.Struct({ |
| 9 | 18 | cwd: TrimmedNonEmptyString, |
| 10 | − query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), | |
| 19 | + // An empty query is a bounded browse: the index returns frecency-ordered | |
| 20 | + // entries, which the file picker uses for its initial results. | |
| 21 | + query: TrimmedString.check(Schema.isMaxLength(256)), | |
| 11 | 22 | limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), |
| 23 | + kind: Schema.optional(ProjectEntryKind), | |
| 12 | 24 | }); |
| 13 | 25 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; |
| 14 | 26 | |
| 15 | −const ProjectEntryKind = Schema.Literals(["file", "directory"]); | |
| 16 | − | |
| 17 | 27 | export const ProjectEntry = Schema.Struct({ |
| 18 | 28 | path: TrimmedNonEmptyString, |
| 19 | 29 | kind: ProjectEntryKind, |
| @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ · 6 unmodified lines | ||
| 26 | 36 | }); |
| 27 | 37 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
| 28 | 38 | |
| 39 | +export const ProjectSearchContentsInput = Schema.Struct({ | |
| 40 | + cwd: TrimmedNonEmptyString, | |
| 41 | + // Whitespace is significant in content queries (" foo", regex trailing | |
| 42 | + // spaces), so the query is deliberately not trimmed on the wire. | |
| 43 | + query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), | |
| 44 | + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), | |
| 45 | + caseSensitive: Schema.Boolean, | |
| 46 | + wholeWord: Schema.Boolean, | |
| 47 | + useRegex: Schema.Boolean, | |
| 48 | +}); | |
| 49 | +export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; | |
| 50 | + | |
| 51 | +export const ProjectContentMatchRange = Schema.Struct({ | |
| 52 | + start: NonNegativeInt, | |
| 53 | + end: NonNegativeInt, | |
| 54 | +}); | |
| 55 | +export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; | |
| 56 | + | |
| 57 | +export const ProjectContentMatch = Schema.Struct({ | |
| 58 | + path: TrimmedNonEmptyString, | |
| 59 | + lineNumber: PositiveInt, | |
| 60 | + lineContent: Schema.String, | |
| 61 | + matchRanges: Schema.Array(ProjectContentMatchRange), | |
| 62 | +}); | |
| 63 | +export type ProjectContentMatch = typeof ProjectContentMatch.Type; | |
| 64 | + | |
| 65 | +export const ProjectSearchContentsResult = Schema.Struct({ | |
| 66 | + matches: Schema.Array(ProjectContentMatch), | |
| 67 | + truncated: Schema.Boolean, | |
| 68 | + regexFallbackError: Schema.optional(Schema.String), | |
| 69 | +}); | |
| 70 | +export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; | |
| 71 | + | |
| 29 | 72 | export const ProjectListEntriesInput = Schema.Struct({ |
| 30 | 73 | cwd: TrimmedNonEmptyString, |
| 31 | 74 | }); |
| @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe · 62 unmodified lines | ||
| 94 | 137 | } |
| 95 | 138 | } |
| 96 | 139 | |
| 140 | +export class ProjectSearchContentsError extends Schema.TaggedErrorClass<ProjectSearchContentsError>()( | |
| 141 | + "ProjectSearchContentsError", | |
| 142 | + { | |
| 143 | + cwd: Schema.optional(TrimmedNonEmptyString), | |
| 144 | + queryLength: Schema.optional(NonNegativeInt), | |
| 145 | + limit: Schema.optional(PositiveInt), | |
| 146 | + failure: Schema.optional(ProjectEntriesFailure), | |
| 147 | + normalizedCwd: Schema.optional(TrimmedNonEmptyString), | |
| 148 | + timeout: Schema.optional(TrimmedNonEmptyString), | |
| 149 | + detail: Schema.optional(TrimmedNonEmptyString), | |
| 150 | + message: TrimmedNonEmptyString, | |
| 151 | + cause: Schema.optional(Schema.Defect()), | |
| 152 | + }, | |
| 153 | +) { | |
| 154 | + // @effect-diagnostics-next-line overriddenSchemaConstructor:off | |
| 155 | + constructor( | |
| 156 | + props: ProjectEntriesFailureContext & { | |
| 157 | + readonly cwd: string; | |
| 158 | + readonly queryLength: number; | |
| 159 | + readonly limit: number; | |
| 160 | + }, | |
| 161 | + ) { | |
| 162 | + super({ | |
| 163 | + ...props, | |
| 164 | + message: | |
| 165 | + decodedProjectErrorMessage(props) ?? | |
| 166 | + `Failed to search workspace contents in '${props.cwd}'.`, | |
| 167 | + } as any); | |
| 168 | + } | |
| 169 | +} | |
| 170 | + | |
| 97 | 171 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( |
| 98 | 172 | "ProjectListEntriesError", |
| 99 | 173 | { |
| @@ -76,6 +76,9 @@ import { · 75 unmodified lines | ||
| 76 | 76 | ProjectReadFileError, |
| 77 | 77 | ProjectReadFileInput, |
| 78 | 78 | ProjectReadFileResult, |
| 79 | + ProjectSearchContentsError, | |
| 80 | + ProjectSearchContentsInput, | |
| 81 | + ProjectSearchContentsResult, | |
| 79 | 82 | ProjectSearchEntriesError, |
| 80 | 83 | ProjectSearchEntriesInput, |
| 81 | 84 | ProjectSearchEntriesResult, |
| @@ -166,6 +169,7 @@ export const WS_METHODS = { · 84 unmodified lines | ||
| 166 | 169 | projectsRemove: "projects.remove", |
| 167 | 170 | projectsListEntries: "projects.listEntries", |
| 168 | 171 | projectsReadFile: "projects.readFile", |
| 172 | + projectsSearchContents: "projects.searchContents", | |
| 169 | 173 | projectsSearchEntries: "projects.searchEntries", |
| 170 | 174 | projectsWriteFile: "projects.writeFile", |
| 171 | 175 | |
| @@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr · 266 unmodified lines | ||
| 438 | 442 | error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), |
| 439 | 443 | }); |
| 440 | 444 | |
| 445 | +export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { | |
| 446 | + payload: ProjectSearchContentsInput, | |
| 447 | + success: ProjectSearchContentsResult, | |
| 448 | + error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), | |
| 449 | +}); | |
| 450 | + | |
| 441 | 451 | export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { |
| 442 | 452 | payload: ProjectListEntriesInput, |
| 443 | 453 | success: ProjectListEntriesResult, |
| @@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make( · 357 unmodified lines | ||
| 801 | 811 | WsSourceControlPublishRepositoryRpc, |
| 802 | 812 | WsProjectsListEntriesRpc, |
| 803 | 813 | WsProjectsReadFileRpc, |
| 814 | + WsProjectsSearchContentsRpc, | |
| 804 | 815 | WsProjectsSearchEntriesRpc, |
| 805 | 816 | WsProjectsWriteFileRpc, |
| 806 | 817 | WsShellOpenInEditorRpc, |
| @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 34 unmodified lines | ||
| 35 | 35 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, |
| 36 | 36 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, |
| 37 | 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 38 | + { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, | |
| 39 | + { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, | |
| 38 | 40 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, |
| 39 | 41 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 40 | 42 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |