| author | |
| committer | |
| log | abc409c2d4a072c2de46c9015f5cffff00dcc46b |
| tree | 4a537759cab418f898c282bcefcd095d2c2daa38 |
| parent | 1877237c7ae99ecf2163b4c5bb7d3e14bebcca4d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>39 files changed, 2928 insertions(+), 476 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 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, |
apps/server/src/keybindings.test.ts+2| ... | ... | @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 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"); |
apps/server/src/keybindings.ts+13-8| ... | ... | @@ -547,19 +547,24 @@ const make = Effect.gen(function* () { |
| 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 | ); |
apps/server/src/workspace/WorkspaceEntries.test.ts+343-1| ... | ... | @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) |
| 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) => { |
| 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) => { |
| 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* () { |
apps/server/src/workspace/WorkspaceEntries.ts+71-34| ... | ... | @@ -14,11 +14,14 @@ import type { |
| 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< |
| 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* () { |
| 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* () { |
| 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( |
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+139-4| ... | ... | @@ -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 |
| 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", () => |
| 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", () => |
| 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", () => |
| 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", () => |
| 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", () => |
| 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", () => |
| 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 | ); |
apps/server/src/workspace/WorkspaceSearchIndex.ts+275-40| ... | ... | @@ -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< |
| 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 { |
| 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( |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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", |
apps/server/src/ws.ts+18| ... | ... | @@ -37,6 +37,7 @@ import { |
| 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 = ( |
| 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, |
apps/web/src/components/ChatMarkdown.tsx+5-47| ... | ... | @@ -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"; |
| 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 { |
| 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>( |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 1614 | 1572 | isStreaming={isStreaming} |
| 1615 | 1573 | /> |
| 1616 | 1574 | </Suspense> |
| 1617 | </CodeHighlightErrorBoundary> | |
| 1575 | </RenderErrorBoundary> | |
| 1618 | 1576 | </MarkdownCodeBlock> |
| 1619 | 1577 | ); |
| 1620 | 1578 | }, |
apps/web/src/components/CommandPalette.logic.test.ts+72| ... | ... | @@ -6,9 +6,81 @@ import { |
| 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) => ({ |
apps/web/src/components/CommandPalette.logic.ts+50-1| ... | ... | @@ -15,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12; |
| 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 { |
| 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; |
apps/web/src/components/CommandPalette.tsx+305-279| ... | ... | @@ -28,16 +28,16 @@ import { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 }) { |
| 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 }) { |
| 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 }) { |
| 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: { |
| 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: { |
| 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 | } |
apps/web/src/components/CommandPaletteContent.tsx created+77| ... | ... | @@ -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 | } |
apps/web/src/components/RenderErrorBoundary.tsx created+16| ... | ... | @@ -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 | } |
apps/web/src/components/files/FileBrowserPanel.tsx+72| ... | ... | @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 |
apps/web/src/components/files/FilePreviewPanel.tsx+140-48| ... | ... | @@ -51,6 +51,7 @@ import { |
| 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): |
| 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( |
| 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( |
| 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({ |
| 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> |
apps/web/src/components/files/ProjectFilePicker.logic.test.ts created+83| ... | ... | @@ -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 | }); |
apps/web/src/components/files/ProjectFilePicker.logic.ts created+73| ... | ... | @@ -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 | } |
apps/web/src/components/files/ProjectFilePicker.tsx created+163| ... | ... | @@ -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 | } |
apps/web/src/components/files/fileLineReveal.test.ts created+32| ... | ... | @@ -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 | }); |
apps/web/src/components/files/fileLineReveal.ts created+24| ... | ... | @@ -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 | } |
apps/web/src/components/files/projectFilesQueryState.ts+27| ... | ... | @@ -11,6 +11,7 @@ import { useCallback } from "react"; |
| 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( |
| 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, |
apps/web/src/components/search/HighlightedSearchLine.tsx created+183| ... | ... | @@ -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 | }); |
apps/web/src/components/search/ProjectContentSearchDialog.tsx created+315| ... | ... | @@ -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 | } |
apps/web/src/hooks/useActiveProjectTarget.ts created+41| ... | ... | @@ -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 | } |
apps/web/src/hooks/useHandleNewThread.ts+1-1| ... | ... | @@ -190,7 +190,7 @@ export function useNewThreadHandler() { |
| 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 | }, |
apps/web/src/keybindings.test.ts+52| ... | ... | @@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ |
| 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", () => { |
| 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", () => { |
| 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, { |
apps/web/src/lib/syntaxHighlighting.test.ts created+28| ... | ... | @@ -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 | }); |
apps/web/src/lib/syntaxHighlighting.ts created+30| ... | ... | @@ -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 | } |
apps/web/src/state/projects.ts+13| ... | ... | @@ -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, |
apps/web/src/state/queries.test.ts created+25| ... | ... | @@ -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 | }); |
apps/web/src/state/queries.ts+88-9| ... | ... | @@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 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"; |
| 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) { |
| 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) { |
| 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 }, |
docs/user/keybindings.md+4| ... | ... | @@ -37,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. |
| 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 |
packages/contracts/src/keybindings.test.ts+12| ... | ... | @@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () => |
| 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", |
packages/contracts/src/keybindings.ts+2| ... | ... | @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 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", |
packages/contracts/src/project.test.ts+42| ... | ... | @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; |
| 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", () => { |
| 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", () => { |
packages/contracts/src/project.ts+78-4| ... | ... | @@ -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({ |
| 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 |
| 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 | { |
packages/contracts/src/rpc.ts+11| ... | ... | @@ -76,6 +76,9 @@ import { |
| 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 = { |
| 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 |
| 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( |
| 801 | 811 | WsSourceControlPublishRepositoryRpc, |
| 802 | 812 | WsProjectsListEntriesRpc, |
| 803 | 813 | WsProjectsReadFileRpc, |
| 814 | WsProjectsSearchContentsRpc, | |
| 804 | 815 | WsProjectsSearchEntriesRpc, |
| 805 | 816 | WsProjectsWriteFileRpc, |
| 806 | 817 | WsShellOpenInEditorRpc, |
packages/shared/src/keybindings.ts+2| ... | ... | @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 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" }, |