| 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,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 55 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, | 55 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, |
| 56 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, | 56 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, |
| 57 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, | 57 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, |
| 58 | [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, | ||
| 58 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, | 59 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, |
| 59 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | 60 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, |
| 60 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 61 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
apps/server/src/keybindings.test.ts+2| ... | @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); | 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); | 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 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); | 203 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); |
| 202 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); | 204 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); |
| 203 | assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); | 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,19 +547,24 @@ const make = Effect.gen(function* () { |
| 547 | }); | 547 | }); |
| 548 | } | 548 | } |
| 549 | 549 | ||
| 550 | const nextConfig = [...customConfig, ...missingDefaults]; | 550 | // Startup backfill must never evict persisted user rules: append only |
| 551 | const cappedConfig = | 551 | // the defaults that fit and skip the rest. |
| 552 | nextConfig.length > MAX_KEYBINDINGS_COUNT | 552 | const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); |
| 553 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | 553 | const defaultsToAppend = missingDefaults.slice(0, availableSlots); |
| 554 | : nextConfig; | 554 | const skippedDefaults = missingDefaults.slice(availableSlots); |
| 555 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | 555 | if (skippedDefaults.length > 0) { |
| 556 | yield* Effect.logWarning("truncating keybindings config to max entries", { | 556 | yield* Effect.logWarning("skipping default keybinding backfill at max entries", { |
| 557 | path: keybindingsConfigPath, | 557 | path: keybindingsConfigPath, |
| 558 | maxEntries: MAX_KEYBINDINGS_COUNT, | 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 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | 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,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) |
| 72 | return result.stdout.trim(); | 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 | Effect.gen(function* () { | 81 | Effect.gen(function* () { |
| 77 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 82 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 78 | return yield* workspaceEntries.search(input); | 83 | return yield* workspaceEntries.search(input); |
| ... | @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { | ... | @@ -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 | it.effect("excludes gitignored paths for git repositories", () => | 264 | it.effect("excludes gitignored paths for git repositories", () => |
| 204 | Effect.gen(function* () { | 265 | Effect.gen(function* () { |
| 205 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); | 266 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); |
| ... | @@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { | ... | @@ -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 | describe("browse", () => { | 637 | describe("browse", () => { |
| 296 | it.effect("returns matching directories and excludes files", () => | 638 | it.effect("returns matching directories and excludes files", () => |
| 297 | Effect.gen(function* () { | 639 | Effect.gen(function* () { |
apps/server/src/workspace/WorkspaceEntries.ts+71-34| ... | @@ -14,11 +14,14 @@ import type { | ... | @@ -14,11 +14,14 @@ import type { |
| 14 | FilesystemBrowseResult, | 14 | FilesystemBrowseResult, |
| 15 | ProjectListEntriesInput, | 15 | ProjectListEntriesInput, |
| 16 | ProjectListEntriesResult, | 16 | ProjectListEntriesResult, |
| 17 | ProjectSearchContentsInput, | ||
| 18 | ProjectSearchContentsResult, | ||
| 17 | ProjectSearchEntriesInput, | 19 | ProjectSearchEntriesInput, |
| 18 | ProjectSearchEntriesResult, | 20 | ProjectSearchEntriesResult, |
| 19 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 22 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | 23 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 24 | import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; | ||
| 22 | 25 | ||
| 23 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | 26 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 24 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; | 27 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| ... | @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< | ... | @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< |
| 93 | readonly search: ( | 96 | readonly search: ( |
| 94 | input: ProjectSearchEntriesInput, | 97 | input: ProjectSearchEntriesInput, |
| 95 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; | 98 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; |
| 99 | readonly searchContents: ( | ||
| 100 | input: ProjectSearchContentsInput, | ||
| 101 | ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>; | ||
| 96 | readonly refresh: (cwd: string) => Effect.Effect<void>; | 102 | readonly refresh: (cwd: string) => Effect.Effect<void>; |
| 97 | } | 103 | } |
| 98 | >()("t3/workspace/WorkspaceEntries") {} | 104 | >()("t3/workspace/WorkspaceEntries") {} |
| ... | @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { | ... | @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { |
| 148 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( | 154 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( |
| 149 | Effect.orElseSucceed(() => cwd), | 155 | Effect.orElseSucceed(() => cwd), |
| 150 | ); | 156 | ); |
| 151 | if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { | 157 | for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { |
| 152 | return; | 158 | const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); |
| 153 | } | 159 | if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { |
| 154 | const recoverRefreshFailure = ( | 160 | continue; |
| 155 | cause: | 161 | } |
| 156 | | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed | 162 | const recoverRefreshFailure = ( |
| 157 | | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut | 163 | cause: |
| 158 | | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, | 164 | | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed |
| 159 | ) => | 165 | | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut |
| 160 | Effect.gen(function* () { | 166 | | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, |
| 161 | yield* Effect.logWarning("Failed to refresh workspace search index", { | 167 | ) => |
| 162 | cwd, | 168 | Effect.gen(function* () { |
| 163 | cause, | 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); | 176 | yield* Effect.gen(function* () { |
| 166 | }); | 177 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 167 | yield* Effect.gen(function* () { | 178 | yield* searchIndex.refresh(); |
| 168 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | 179 | }).pipe( |
| 169 | yield* searchIndex.refresh(); | 180 | Effect.provide(workspaceSearchIndexes.get(indexKey)), |
| 170 | }).pipe( | 181 | Effect.catchTags({ |
| 171 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | 182 | WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, |
| 172 | Effect.catchTags({ | 183 | WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, |
| 173 | WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, | 184 | WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, |
| 174 | WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, | 185 | }), |
| 175 | WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, | 186 | ); |
| 176 | }), | 187 | } |
| 177 | ); | ||
| 178 | }, | 188 | }, |
| 179 | ); | 189 | ); |
| 180 | 190 | ||
| ... | @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { | ... | @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { |
| 230 | const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( | 240 | const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( |
| 231 | function* (input) { | 241 | function* (input) { |
| 232 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | 242 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); |
| 233 | const normalizedQuery = input.query | 243 | const normalizedQuery = normalizeSearchQuery(input.query, { |
| 234 | .trim() | 244 | trimLeadingPattern: /^[@./]+/, |
| 235 | .toLowerCase() | 245 | }); |
| 236 | .replace(/^[@./]+/, ""); | ||
| 237 | return yield* Effect.gen(function* () { | 246 | return yield* Effect.gen(function* () { |
| 238 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | 247 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 239 | return yield* searchIndex.search(normalizedQuery, input.limit); | 248 | return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); |
| 240 | }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); | 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 | const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( | 275 | const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( |
| 245 | function* (input) { | 276 | function* (input) { |
| 246 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | 277 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); |
| 247 | return yield* Effect.gen(function* () { | 278 | return yield* Effect.gen(function* () { |
| 248 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | 279 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 249 | return yield* searchIndex.list(); | 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 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( | 294 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( |
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+139-4| ... | @@ -1,4 +1,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 | import { afterEach, expect, it } from "@effect/vitest"; | 2 | import { afterEach, expect, it } from "@effect/vitest"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain | ... | @@ -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 | it.effect("preserves FileFinder destroy failures as structured defects", () => | 89 | it.effect("preserves FileFinder destroy failures as structured defects", () => |
| 55 | Effect.gen(function* () { | 90 | Effect.gen(function* () { |
| 56 | const cause = new Error("native destroy failed"); | 91 | const cause = new Error("native destroy failed"); |
| ... | @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => | ... | @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => |
| 58 | destroy: vi.fn(() => { | 93 | destroy: vi.fn(() => { |
| 59 | throw cause; | 94 | throw cause; |
| 60 | }), | 95 | }), |
| 61 | isScanning: vi.fn(() => false), | 96 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 62 | } as unknown as FileFinder; | 97 | } as unknown as FileFinder; |
| 63 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); | 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,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => |
| 85 | Effect.gen(function* () { | 120 | Effect.gen(function* () { |
| 86 | const searchCause = new Error("native search failed"); | 121 | const searchCause = new Error("native search failed"); |
| 87 | const refreshCause = new Error("native scan failed"); | 122 | const refreshCause = new Error("native scan failed"); |
| 123 | const contentSearchCause = new Error("native grep failed"); | ||
| 88 | const finder = { | 124 | const finder = { |
| 89 | destroy: vi.fn(), | 125 | destroy: vi.fn(), |
| 90 | isScanning: vi.fn(() => false), | 126 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 91 | mixedSearch: vi.fn(() => { | 127 | mixedSearch: vi.fn(() => { |
| 92 | throw searchCause; | 128 | throw searchCause; |
| 93 | }), | 129 | }), |
| 130 | grep: vi.fn(() => { | ||
| 131 | throw contentSearchCause; | ||
| 132 | }), | ||
| 94 | scanFiles: vi.fn(() => { | 133 | scanFiles: vi.fn(() => { |
| 95 | throw refreshCause; | 134 | throw refreshCause; |
| 96 | }), | 135 | }), |
| ... | @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => | ... | @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => |
| 100 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); | 139 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); |
| 101 | const query = "authorization: Bearer secret-token"; | 140 | const query = "authorization: Bearer secret-token"; |
| 102 | const searchError = yield* Effect.flip(searchIndex.search(query, 3)); | 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 | const refreshError = yield* Effect.flip(searchIndex.refresh()); | 151 | const refreshError = yield* Effect.flip(searchIndex.refresh()); |
| 104 | 152 | ||
| 105 | expect(searchError).toMatchObject({ | 153 | expect(searchError).toMatchObject({ |
| ... | @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => | ... | @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => |
| 112 | }); | 160 | }); |
| 113 | expect(searchError).not.toHaveProperty("query"); | 161 | expect(searchError).not.toHaveProperty("query"); |
| 114 | expect(searchError.message).not.toMatch(/Bearer|secret-token/); | 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 | expect(refreshError).toMatchObject({ | 173 | expect(refreshError).toMatchObject({ |
| 116 | _tag: "WorkspaceSearchIndexRefreshFailed", | 174 | _tag: "WorkspaceSearchIndexRefreshFailed", |
| 117 | cwd: "/workspace/project", | 175 | cwd: "/workspace/project", |
| ... | @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => | ... | @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => |
| 127 | Effect.gen(function* () { | 185 | Effect.gen(function* () { |
| 128 | const finder = { | 186 | const finder = { |
| 129 | destroy: vi.fn(), | 187 | destroy: vi.fn(), |
| 130 | isScanning: vi.fn(() => false), | 188 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 131 | mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), | 189 | mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), |
| 132 | scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), | 190 | scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), |
| 133 | } as unknown as FileFinder; | 191 | } as unknown as FileFinder; |
| ... | @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => | ... | @@ -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,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 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 5 | import * as LayerMap from "effect/LayerMap"; | 15 | import * as LayerMap from "effect/LayerMap"; |
| 6 | import * as Schedule from "effect/Schedule"; | ||
| 7 | import * as Schema from "effect/Schema"; | 16 | import * as Schema from "effect/Schema"; |
| 8 | 17 | ||
| 9 | import type { | 18 | import type { |
| 10 | ProjectEntry, | 19 | ProjectEntry, |
| 20 | ProjectEntryKind, | ||
| 11 | ProjectListEntriesResult, | 21 | ProjectListEntriesResult, |
| 22 | ProjectSearchContentsInput, | ||
| 23 | ProjectSearchContentsResult, | ||
| 12 | ProjectSearchEntriesResult, | 24 | ProjectSearchEntriesResult, |
| 13 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 14 | 26 | ||
| 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | 27 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 16 | const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; | 28 | const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; |
| 17 | const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; | 29 | const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; |
| 30 | const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; | ||
| 18 | const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; | 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 | export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()( | 35 | export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()( |
| 22 | "WorkspaceSearchIndexCreateFailed", | 36 | "WorkspaceSearchIndexCreateFailed", |
| ... | @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< | ... | @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< |
| 96 | readonly search: ( | 110 | readonly search: ( |
| 97 | query: string, | 111 | query: string, |
| 98 | limit: number, | 112 | limit: number, |
| 113 | kind?: ProjectEntryKind, | ||
| 99 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>; | 114 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>; |
| 115 | readonly searchContents: ( | ||
| 116 | input: Omit<ProjectSearchContentsInput, "cwd">, | ||
| 117 | ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceSearchIndexSearchFailed>; | ||
| 100 | readonly refresh: () => Effect.Effect< | 118 | readonly refresh: () => Effect.Effect< |
| 101 | void, | 119 | void, |
| 102 | WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut | 120 | WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut |
| ... | @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { | ... | @@ -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 | function mapMixedSearchResult( | 187 | function mapMixedSearchResult( |
| 133 | result: MixedSearchResult, | 188 | result: MixedSearchResult, |
| 134 | limit: number, | 189 | limit: number, |
| ... | @@ -155,6 +210,74 @@ function mapMixedSearchResult( | ... | @@ -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 | function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] { | 281 | function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] { |
| 159 | const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); | 282 | const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); |
| 160 | for (const entry of entries) { | 283 | for (const entry of entries) { |
| ... | @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn | ... | @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn |
| 169 | return [...entryByPath.values()]; | 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 | const result = yield* Effect.try({ | 299 | const result = yield* Effect.try({ |
| 174 | try: () => | 300 | try: () => |
| 175 | FileFinder.create({ | 301 | FileFinder.create({ |
| 176 | basePath: cwd, | 302 | basePath: cwd, |
| 177 | disableMmapCache: true, | 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 | aiMode: false, | 308 | aiMode: false, |
| 180 | enableFsRootScanning: true, | 309 | enableFsRootScanning: true, |
| 181 | enableHomeDirScanning: true, | 310 | enableHomeDirScanning: true, |
| ... | @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c | ... | @@ -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) => | 326 | const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>( |
| 198 | Effect.try({ | 327 | cwd: string, |
| 199 | try: () => finder.isScanning(), | 328 | finder: FileFinder, |
| 200 | catch: onFailure, | 329 | onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, |
| 201 | }).pipe( | 330 | ): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> { |
| 202 | Effect.repeat({ | 331 | const result = yield* Effect.tryPromise({ |
| 203 | while: (scanning) => scanning, | 332 | try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), |
| 204 | schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), | 333 | catch: (cause) => |
| 205 | }), | 334 | onFailure({ |
| 206 | Effect.timeoutOrElse({ | 335 | reason: "FileFinder.waitForIndexReady rejected unexpectedly.", |
| 207 | duration: WORKSPACE_INDEX_SCAN_TIMEOUT, | 336 | cause, |
| 208 | orElse: () => | 337 | }), |
| 209 | new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), | 338 | }); |
| 210 | }), | 339 | if (!result.ok) { |
| 211 | Effect.withSpan("WorkspaceSearchIndex.waitForScan"), | 340 | return yield* Effect.fail(onFailure({ reason: result.error })); |
| 212 | ); | 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) { | 350 | export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( |
| 215 | const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => | 351 | cwd: string, |
| 352 | variant: WorkspaceSearchIndexVariant = "paths", | ||
| 353 | ) { | ||
| 354 | const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => | ||
| 216 | Effect.try({ | 355 | Effect.try({ |
| 217 | try: () => finder.destroy(), | 356 | try: () => finder.destroy(), |
| 218 | catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), | 357 | catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), |
| 219 | }).pipe(Effect.orDie), | 358 | }).pipe(Effect.orDie), |
| 220 | ); | 359 | ); |
| 221 | yield* waitForScan( | 360 | yield* waitForIndexReady( |
| 222 | cwd, | 361 | cwd, |
| 223 | finder, | 362 | finder, |
| 224 | (cause) => | 363 | ({ reason, cause }) => |
| 225 | new WorkspaceSearchIndexCreateFailed({ | 364 | new WorkspaceSearchIndexCreateFailed({ |
| 226 | cwd, | 365 | cwd, |
| 227 | reason: "FileFinder.isScanning threw while creating the index.", | 366 | reason, |
| 228 | cause, | 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 | query: string, | 372 | query: string, |
| 234 | pageSize: number, | 373 | pageSize: number, |
| 235 | ) { | 374 | operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", |
| 375 | execute: () => Result<A>, | ||
| 376 | ): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> { | ||
| 236 | const result = yield* Effect.try({ | 377 | const result = yield* Effect.try({ |
| 237 | try: () => finder.mixedSearch(query, { pageSize }), | 378 | try: execute, |
| 238 | catch: (cause) => | 379 | catch: (cause) => |
| 239 | new WorkspaceSearchIndexSearchFailed({ | 380 | new WorkspaceSearchIndexSearchFailed({ |
| 240 | cwd, | 381 | cwd, |
| 241 | queryLength: query.length, | 382 | queryLength: query.length, |
| 242 | pageSize, | 383 | pageSize, |
| 243 | reason: "FileFinder.mixedSearch threw unexpectedly.", | 384 | reason: `FileFinder.${operation} threw unexpectedly.`, |
| 244 | cause, | 385 | cause, |
| 245 | }), | 386 | }), |
| 246 | }); | 387 | }); |
| ... | @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin | ... | @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin |
| 273 | reason: result.error, | 414 | reason: result.error, |
| 274 | }); | 415 | }); |
| 275 | } | 416 | } |
| 276 | yield* waitForScan( | 417 | yield* waitForIndexReady( |
| 277 | cwd, | 418 | cwd, |
| 278 | finder, | 419 | finder, |
| 279 | (cause) => | 420 | ({ reason, cause }) => |
| 280 | new WorkspaceSearchIndexRefreshFailed({ | 421 | new WorkspaceSearchIndexRefreshFailed({ |
| 281 | cwd, | 422 | cwd, |
| 282 | reason: "FileFinder.isScanning threw while refreshing the index.", | 423 | reason, |
| 283 | cause, | 424 | cause, |
| 284 | }), | 425 | }), |
| 285 | ); | 426 | ); |
| ... | @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin | ... | @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin |
| 287 | 428 | ||
| 288 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( | 429 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( |
| 289 | function* () { | 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 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); | 434 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); |
| 292 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => | 435 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => |
| 293 | left.path.localeCompare(right.path), | 436 | left.path.localeCompare(right.path), |
| ... | @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin | ... | @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin |
| 302 | 445 | ||
| 303 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( | 446 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( |
| 304 | "WorkspaceSearchIndex.search", | 447 | "WorkspaceSearchIndex.search", |
| 305 | )(function* (query, limit) { | 448 | )(function* (query, limit, kind) { |
| 306 | const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); | 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 | return mapMixedSearchResult(result, limit); | 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 | * A layer factory is required because every index is scoped to a concrete | 545 | * A layer factory is required because every index is scoped to a concrete |
| 315 | * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; | 546 | * workspace root and variant. WorkspaceSearchIndexMap owns memoization and |
| 316 | * using a default cwd here would mix resources from different workspaces. | 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 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( | 555 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( |
| 321 | "t3/workspace/WorkspaceSearchIndexMap", | 556 | "t3/workspace/WorkspaceSearchIndexMap", |
apps/server/src/ws.ts+18| ... | @@ -37,6 +37,7 @@ import { | ... | @@ -37,6 +37,7 @@ import { |
| 37 | type ProjectFileOperation, | 37 | type ProjectFileOperation, |
| 38 | ProjectListEntriesError, | 38 | ProjectListEntriesError, |
| 39 | ProjectReadFileError, | 39 | ProjectReadFileError, |
| 40 | ProjectSearchContentsError, | ||
| 40 | ProjectSearchEntriesError, | 41 | ProjectSearchEntriesError, |
| 41 | ProjectWriteFileError, | 42 | ProjectWriteFileError, |
| 42 | RelayClientInstallFailedError, | 43 | RelayClientInstallFailedError, |
| ... | @@ -1597,6 +1598,23 @@ const makeWsRpcLayer = ( | ... | @@ -1597,6 +1598,23 @@ const makeWsRpcLayer = ( |
| 1597 | ), | 1598 | ), |
| 1598 | { "rpc.aggregate": "workspace" }, | 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 | [WS_METHODS.projectsListEntries]: (input) => | 1618 | [WS_METHODS.projectsListEntries]: (input) => |
| 1601 | observeRpcEffect( | 1619 | observeRpcEffect( |
| 1602 | WS_METHODS.projectsListEntries, | 1620 | WS_METHODS.projectsListEntries, |
apps/web/src/components/ChatMarkdown.tsx+5-47| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; | ||
| 3 | import { | 2 | import { |
| 4 | CheckIcon, | 3 | CheckIcon, |
| 5 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| ... | @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; | ... | @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 57 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 56 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 58 | import { fnv1a32 } from "../lib/diffRendering"; | 57 | import { fnv1a32 } from "../lib/diffRendering"; |
| 59 | import { LRUCache } from "../lib/lruCache"; | 58 | import { LRUCache } from "../lib/lruCache"; |
| 59 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | ||
| 60 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; | ||
| 60 | import { useTheme } from "../hooks/useTheme"; | 61 | import { useTheme } from "../hooks/useTheme"; |
| 61 | import { getClientSettings } from "../hooks/useSettings"; | 62 | import { getClientSettings } from "../hooks/useSettings"; |
| 62 | import { | 63 | import { |
| ... | @@ -91,27 +92,6 @@ import { | ... | @@ -91,27 +92,6 @@ import { |
| 91 | BrowserPreviewUnavailableError, | 92 | BrowserPreviewUnavailableError, |
| 92 | } from "../browser/openFileInPreview"; | 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 | interface ChatMarkdownProps { | 95 | interface ChatMarkdownProps { |
| 116 | text: string; | 96 | text: string; |
| 117 | cwd: string | undefined; | 97 | cwd: string | undefined; |
| ... | @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>( | ... | @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>( |
| 147 | MAX_HIGHLIGHT_CACHE_ENTRIES, | 127 | MAX_HIGHLIGHT_CACHE_ENTRIES, |
| 148 | MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, | 128 | MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, |
| 149 | ); | 129 | ); |
| 150 | const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>(); | ||
| 151 | 130 | ||
| 152 | function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { | 131 | function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { |
| 153 | const firstLineEnd = markdown.indexOf("\n", listItemStart); | 132 | const firstLineEnd = markdown.indexOf("\n", listItemStart); |
| ... | @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { | ... | @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { |
| 333 | return Math.max(html.length * 2, code.length * 3); | 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 | function readInitialWordWrapSetting(): boolean { | 315 | function readInitialWordWrapSetting(): boolean { |
| 358 | return getClientSettings().wordWrap; | 316 | return getClientSettings().wordWrap; |
| 359 | } | 317 | } |
| ... | @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ | ... | @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ |
| 743 | cacheKey, | 701 | cacheKey, |
| 744 | isStreaming, | 702 | isStreaming, |
| 745 | }: UncachedShikiCodeBlockProps) { | 703 | }: UncachedShikiCodeBlockProps) { |
| 746 | const highlighter = use(getHighlighterPromise(language)); | 704 | const highlighter = use(getSyntaxHighlighterPromise(language)); |
| 747 | const highlightedHtml = useMemo(() => { | 705 | const highlightedHtml = useMemo(() => { |
| 748 | try { | 706 | try { |
| 749 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); | 707 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| ... | @@ -1605,7 +1563,7 @@ function ChatMarkdown({ | ... | @@ -1605,7 +1563,7 @@ function ChatMarkdown({ |
| 1605 | fenceTitle={fenceTitle} | 1563 | fenceTitle={fenceTitle} |
| 1606 | theme={resolvedTheme} | 1564 | theme={resolvedTheme} |
| 1607 | > | 1565 | > |
| 1608 | <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}> | 1566 | <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}> |
| 1609 | <Suspense fallback={<pre {...props}>{children}</pre>}> | 1567 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 1610 | <SuspenseShikiCodeBlock | 1568 | <SuspenseShikiCodeBlock |
| 1611 | className={codeBlock.className} | 1569 | className={codeBlock.className} |
| ... | @@ -1614,7 +1572,7 @@ function ChatMarkdown({ | ... | @@ -1614,7 +1572,7 @@ function ChatMarkdown({ |
| 1614 | isStreaming={isStreaming} | 1572 | isStreaming={isStreaming} |
| 1615 | /> | 1573 | /> |
| 1616 | </Suspense> | 1574 | </Suspense> |
| 1617 | </CodeHighlightErrorBoundary> | 1575 | </RenderErrorBoundary> |
| 1618 | </MarkdownCodeBlock> | 1576 | </MarkdownCodeBlock> |
| 1619 | ); | 1577 | ); |
| 1620 | }, | 1578 | }, |
apps/web/src/components/CommandPalette.logic.test.ts+72| ... | @@ -6,9 +6,81 @@ import { | ... | @@ -6,9 +6,81 @@ import { |
| 6 | buildThreadActionItems, | 6 | buildThreadActionItems, |
| 7 | enumerateCommandPaletteItems, | 7 | enumerateCommandPaletteItems, |
| 8 | filterCommandPaletteGroups, | 8 | filterCommandPaletteGroups, |
| 9 | reduceCommandPaletteUiState, | ||
| 9 | type CommandPaletteGroup, | 10 | type CommandPaletteGroup, |
| 10 | } from "./CommandPalette.logic"; | 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 | describe("enumerateCommandPaletteItems", () => { | 84 | describe("enumerateCommandPaletteItems", () => { |
| 13 | it("assigns positional jump shortcuts to the first nine displayed items", () => { | 85 | it("assigns positional jump shortcuts to the first nine displayed items", () => { |
| 14 | const items = Array.from({ length: 10 }, (_, index) => ({ | 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,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12; |
| 15 | export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; | 15 | export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; |
| 16 | export const ADDON_ICON_CLASS = "size-4"; | 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 | export interface CommandPaletteThreadContentMatch { | 67 | export interface CommandPaletteThreadContentMatch { |
| 19 | readonly source: "user" | "assistant"; | 68 | readonly source: "user" | "assistant"; |
| 20 | readonly snippet: string; | 69 | readonly snippet: string; |
| ... | @@ -26,7 +75,7 @@ export interface CommandPaletteItem { | ... | @@ -26,7 +75,7 @@ export interface CommandPaletteItem { |
| 26 | readonly value: string; | 75 | readonly value: string; |
| 27 | readonly searchTerms: ReadonlyArray<string>; | 76 | readonly searchTerms: ReadonlyArray<string>; |
| 28 | readonly title: ReactNode; | 77 | readonly title: ReactNode; |
| 29 | readonly description?: string; | 78 | readonly description?: ReactNode; |
| 30 | readonly threadContentMatch?: CommandPaletteThreadContentMatch; | 79 | readonly threadContentMatch?: CommandPaletteThreadContentMatch; |
| 31 | readonly timestamp?: string; | 80 | readonly timestamp?: string; |
| 32 | readonly icon: ReactNode; | 81 | readonly icon: ReactNode; |
apps/web/src/components/CommandPalette.tsx+305-279| ... | @@ -28,16 +28,16 @@ import { | ... | @@ -28,16 +28,16 @@ import { |
| 28 | import { useNavigate, useParams } from "@tanstack/react-router"; | 28 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 29 | import * as Option from "effect/Option"; | 29 | import * as Option from "effect/Option"; |
| 30 | import { | 30 | import { |
| 31 | ArrowDownIcon, | ||
| 32 | ArrowLeftIcon, | 31 | ArrowLeftIcon, |
| 33 | ArrowUpIcon, | ||
| 34 | CornerLeftUpIcon, | 32 | CornerLeftUpIcon, |
| 33 | FileSearchIcon, | ||
| 35 | FolderIcon, | 34 | FolderIcon, |
| 36 | FolderPlusIcon, | 35 | FolderPlusIcon, |
| 37 | LinkIcon, | 36 | LinkIcon, |
| 38 | MessageSquareIcon, | 37 | MessageSquareIcon, |
| 39 | SettingsIcon, | 38 | SettingsIcon, |
| 40 | SquarePenIcon, | 39 | SquarePenIcon, |
| 40 | TextSearchIcon, | ||
| 41 | } from "lucide-react"; | 41 | } from "lucide-react"; |
| 42 | import { | 42 | import { |
| 43 | useCallback, | 43 | useCallback, |
| ... | @@ -81,7 +81,9 @@ import { | ... | @@ -81,7 +81,9 @@ import { |
| 81 | resolveProjectPathForDispatch, | 81 | resolveProjectPathForDispatch, |
| 82 | } from "../lib/projectPaths"; | 82 | } from "../lib/projectPaths"; |
| 83 | import { onOpenCommandPalette } from "../commandPaletteBus"; | 83 | import { onOpenCommandPalette } from "../commandPaletteBus"; |
| 84 | import { isPreviewFocused } from "../lib/previewFocus"; | ||
| 84 | import { isTerminalFocused } from "../lib/terminalFocus"; | 85 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 86 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | ||
| 85 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; | 87 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; |
| 86 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; | 88 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; |
| 87 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 89 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| ... | @@ -100,6 +102,7 @@ import { | ... | @@ -100,6 +102,7 @@ import { |
| 100 | buildThreadActionItems, | 102 | buildThreadActionItems, |
| 101 | enumerateCommandPaletteItems, | 103 | enumerateCommandPaletteItems, |
| 102 | type CommandPaletteActionItem, | 104 | type CommandPaletteActionItem, |
| 105 | type CommandPaletteOpenIntent, | ||
| 103 | type CommandPaletteSubmenuItem, | 106 | type CommandPaletteSubmenuItem, |
| 104 | type CommandPaletteView, | 107 | type CommandPaletteView, |
| 105 | filterCommandPaletteGroups, | 108 | filterCommandPaletteGroups, |
| ... | @@ -107,24 +110,22 @@ import { | ... | @@ -107,24 +110,22 @@ import { |
| 107 | getCommandPaletteMode, | 110 | getCommandPaletteMode, |
| 108 | ITEM_ICON_CLASS, | 111 | ITEM_ICON_CLASS, |
| 109 | RECENT_THREAD_LIMIT, | 112 | RECENT_THREAD_LIMIT, |
| 113 | reduceCommandPaletteUiState, | ||
| 114 | type SearchOverlayMode, | ||
| 110 | } from "./CommandPalette.logic"; | 115 | } from "./CommandPalette.logic"; |
| 111 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; | 116 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; |
| 112 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 117 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 118 | import { CommandPaletteContent } from "./CommandPaletteContent"; | ||
| 113 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 119 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 114 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; | 120 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; |
| 115 | import { ProjectFavicon } from "./ProjectFavicon"; | 121 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 122 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; | ||
| 123 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; | ||
| 116 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 124 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 117 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; | 125 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 118 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; | 126 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; |
| 119 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; | 127 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; |
| 120 | import { | 128 | import { CommandDialog, CommandDialogPopup } from "./ui/command"; |
| 121 | Command, | ||
| 122 | CommandDialog, | ||
| 123 | CommandDialogPopup, | ||
| 124 | CommandFooter, | ||
| 125 | CommandInput, | ||
| 126 | CommandPanel, | ||
| 127 | } from "./ui/command"; | ||
| 128 | import { Button } from "./ui/button"; | 129 | import { Button } from "./ui/button"; |
| 129 | import { Kbd, KbdGroup } from "./ui/kbd"; | 130 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 130 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 131 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| ... | @@ -346,50 +347,30 @@ function errorMessage(error: unknown): string { | ... | @@ -346,50 +347,30 @@ function errorMessage(error: unknown): string { |
| 346 | return "An error occurred."; | 347 | return "An error occurred."; |
| 347 | } | 348 | } |
| 348 | 349 | ||
| 349 | interface CommandPaletteOpenIntent { | 350 | const OVERLAY_MODE_BY_COMMAND = { |
| 350 | readonly kind: "add-project" | "new-thread-in"; | 351 | "commandPalette.toggle": "command", |
| 351 | } | 352 | "filePicker.toggle": "files", |
| 352 | 353 | "projectSearch.toggle": "content", | |
| 353 | interface CommandPaletteUiState { | 354 | } as const satisfies Partial<Record<string, SearchOverlayMode>>; |
| 354 | readonly open: boolean; | ||
| 355 | readonly openIntent: CommandPaletteOpenIntent | null; | ||
| 356 | } | ||
| 357 | 355 | ||
| 358 | type CommandPaletteUiAction = | 356 | function overlayModeForCommand(command: string | null): SearchOverlayMode | null { |
| 359 | | { readonly _tag: "SetOpen"; readonly open: boolean } | 357 | if (command === null) return null; |
| 360 | | { readonly _tag: "Toggle" } | 358 | return command in OVERLAY_MODE_BY_COMMAND |
| 361 | | { readonly _tag: "OpenAddProject" } | 359 | ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] |
| 362 | | { readonly _tag: "OpenNewThreadIn" } | 360 | : null; |
| 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 | } | ||
| 384 | } | 361 | } |
| 385 | 362 | ||
| 386 | export function CommandPalette({ children }: { children: ReactNode }) { | 363 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 387 | const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { | 364 | const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { |
| 388 | open: false, | 365 | open: false, |
| 366 | mode: "command", | ||
| 389 | openIntent: null, | 367 | openIntent: null, |
| 390 | }); | 368 | }); |
| 391 | const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); | 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 | const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); | 374 | const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); |
| 394 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); | 375 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); |
| 395 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); | 376 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); |
| ... | @@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 405 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen | 386 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 406 | : false, | 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 | useEffect(() => { | 407 | useEffect(() => { |
| 410 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | 408 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 411 | if (event.defaultPrevented) return; | 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 | const command = resolveShortcutCommand(event, keybindings, { | 412 | const command = resolveShortcutCommand(event, keybindings, { |
| 413 | context: { | 413 | context: { |
| 414 | terminalFocus: isTerminalFocused(), | 414 | terminalFocus: isTerminalFocused(), |
| 415 | terminalOpen, | 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 | return; | 422 | return; |
| 420 | } | 423 | } |
| 421 | event.preventDefault(); | 424 | event.preventDefault(); |
| 422 | event.stopPropagation(); | 425 | event.stopPropagation(); |
| 423 | toggleOpen(); | 426 | toggleMode(mode); |
| 424 | }; | 427 | }; |
| 425 | window.addEventListener("keydown", onKeyDown); | 428 | window.addEventListener("keydown", onKeyDown); |
| 426 | return () => window.removeEventListener("keydown", onKeyDown); | 429 | return () => window.removeEventListener("keydown", onKeyDown); |
| 427 | }, [keybindings, terminalOpen, toggleOpen]); | 430 | }, [keybindings, previewOpen, terminalOpen, toggleMode]); |
| 428 | 431 | ||
| 429 | useEffect( | 432 | useEffect( |
| 430 | () => | 433 | () => |
| ... | @@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 442 | 445 | ||
| 443 | return ( | 446 | return ( |
| 444 | <ComposerHandleContext value={composerHandleRef}> | 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 | {children} | 459 | {children} |
| 447 | <CommandPaletteDialog | 460 | <CommandPaletteDialog |
| 448 | open={state.open} | 461 | open={state.open} |
| 462 | mode={state.mode} | ||
| 449 | openIntent={state.openIntent} | 463 | openIntent={state.openIntent} |
| 450 | setOpen={setOpen} | 464 | setOpen={setOpen} |
| 465 | openOverlayMode={toggleMode} | ||
| 451 | clearOpenIntent={clearOpenIntent} | 466 | clearOpenIntent={clearOpenIntent} |
| 452 | /> | 467 | /> |
| 453 | </CommandDialog> | 468 | </CommandDialog> |
| ... | @@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 457 | 472 | ||
| 458 | function CommandPaletteDialog(props: { | 473 | function CommandPaletteDialog(props: { |
| 459 | readonly open: boolean; | 474 | readonly open: boolean; |
| 475 | readonly mode: SearchOverlayMode; | ||
| 460 | readonly openIntent: CommandPaletteOpenIntent | null; | 476 | readonly openIntent: CommandPaletteOpenIntent | null; |
| 461 | readonly setOpen: (open: boolean) => void; | 477 | readonly setOpen: (open: boolean) => void; |
| 478 | readonly openOverlayMode: (mode: SearchOverlayMode) => void; | ||
| 462 | readonly clearOpenIntent: () => void; | 479 | readonly clearOpenIntent: () => void; |
| 463 | }) { | 480 | }) { |
| 481 | const composerHandleRef = useComposerHandleContext(); | ||
| 482 | |||
| 464 | if (!props.open) { | 483 | if (!props.open) { |
| 465 | return null; | 484 | return null; |
| 466 | } | 485 | } |
| 467 | 486 | ||
| 468 | return ( | 487 | return ( |
| 469 | <OpenCommandPaletteDialog | 488 | <CommandDialogPopup |
| 470 | openIntent={props.openIntent} | 489 | aria-label={ |
| 471 | setOpen={props.setOpen} | 490 | props.mode === "files" |
| 472 | clearOpenIntent={props.clearOpenIntent} | 491 | ? "File picker" |
| 473 | /> | 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 | function OpenCommandPaletteDialog(props: { | 524 | function OpenCommandPaletteDialog(props: { |
| 478 | readonly openIntent: CommandPaletteOpenIntent | null; | 525 | readonly openIntent: CommandPaletteOpenIntent | null; |
| 479 | readonly setOpen: (open: boolean) => void; | 526 | readonly setOpen: (open: boolean) => void; |
| 527 | readonly openOverlayMode: (mode: SearchOverlayMode) => void; | ||
| 480 | readonly clearOpenIntent: () => void; | 528 | readonly clearOpenIntent: () => void; |
| 481 | }) { | 529 | }) { |
| 482 | const navigate = useNavigate(); | 530 | const navigate = useNavigate(); |
| 483 | const { clearOpenIntent, openIntent, setOpen } = props; | 531 | const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; |
| 484 | const composerHandleRef = useComposerHandleContext(); | ||
| 485 | const [query, setQuery] = useState(""); | 532 | const [query, setQuery] = useState(""); |
| 486 | const deferredQuery = useDeferredValue(query); | 533 | const deferredQuery = useDeferredValue(query); |
| 487 | const isActionsOnly = deferredQuery.startsWith(">"); | 534 | const isActionsOnly = deferredQuery.startsWith(">"); |
| ... | @@ -1346,6 +1393,32 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -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 | actionItems.push({ | 1422 | actionItems.push({ |
| 1350 | kind: "action", | 1423 | kind: "action", |
| 1351 | value: "action:add-project", | 1424 | value: "action:add-project", |
| ... | @@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: { |
| 2062 | primaryEnvironmentId, | 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 | return ( | 2234 | return ( |
| 2066 | <CommandDialogPopup | 2235 | <CommandPaletteContent |
| 2236 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | ||
| 2067 | aria-label="Command palette" | 2237 | aria-label="Command palette" |
| 2068 | className="overflow-hidden p-0" | 2238 | autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} |
| 2069 | data-command-palette="true" | 2239 | footerActionLabel={footerActionLabel} |
| 2070 | data-testid="command-palette" | 2240 | footerTrailing={footerTrailing} |
| 2071 | finalFocus={() => { | 2241 | inputAccessory={inputAccessory} |
| 2072 | composerHandleRef?.current?.focusAtEnd(); | 2242 | inputProps={{ |
| 2073 | return false; | 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={() => { | 2273 | mode="none" |
| 2076 | setOpen(false); | 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 | 2282 | {remoteProjectContext ? ( |
| 2080 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | 2283 | <div className="p-2 pb-0"> |
| 2081 | aria-label="Command palette" | 2284 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div> |
| 2082 | autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} | 2285 | <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5"> |
| 2083 | mode="none" | 2286 | {remoteProjectContext.icon} |
| 2084 | onItemHighlighted={(value) => { | 2287 | <span className="flex min-w-0 flex-1 flex-col"> |
| 2085 | setHighlightedItemValue(typeof value === "string" ? value : null); | 2288 | <span className="truncate text-foreground text-sm">{remoteProjectContext.title}</span> |
| 2086 | }} | 2289 | <span className="truncate text-muted-foreground/85 text-xs"> |
| 2087 | onValueChange={handleQueryChange} | 2290 | {remoteProjectContext.description} |
| 2088 | value={query} | 2291 | </span> |
| 2089 | > | 2292 | </span> |
| 2090 | <div className="relative"> | 2293 | </div> |
| 2091 | <CommandInput | 2294 | </div> |
| 2092 | className={ | 2295 | ) : null} |
| 2093 | addProjectCloneFlow?.step === "repository" | 2296 | <CommandPaletteResults |
| 2094 | ? "pe-32" | 2297 | groups={displayedGroups} |
| 2095 | : isBrowsing | 2298 | highlightedItemValue={highlightedItemValue} |
| 2096 | ? willCreateProjectPath | 2299 | isActionsOnly={isActionsOnly} |
| 2097 | ? "pe-36" | 2300 | keybindings={keybindings} |
| 2098 | : "pe-16" | 2301 | onExecuteItem={executeItem} |
| 2099 | : undefined | 2302 | {...(addProjectCloneFlow?.step === "repository" |
| 2100 | } | 2303 | ? { |
| 2101 | placeholder={inputPlaceholder} | 2304 | emptyStateMessage: |
| 2102 | wrapperClassName={ | 2305 | addProjectCloneFlow.source === "url" |
| 2103 | isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined | 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 | 2309 | : addProjectCloneFlow?.step === "confirm" |
| 2106 | ? { | 2310 | ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } |
| 2107 | startAddon: ( | 2311 | : relativePathNeedsActiveProject |
| 2108 | <button | 2312 | ? { emptyStateMessage: "Relative paths require an active project." } |
| 2109 | type="button" | 2313 | : willCreateProjectPath |
| 2110 | className="flex cursor-pointer items-center" | ||
| 2111 | aria-label="Back" | ||
| 2112 | onClick={popView} | ||
| 2113 | > | ||
| 2114 | <ArrowLeftIcon /> | ||
| 2115 | </button> | ||
| 2116 | ), | ||
| 2117 | } | ||
| 2118 | : isBrowsing && !isSubmenu | ||
| 2119 | ? { | 2314 | ? { |
| 2120 | startAddon: <FolderPlusIcon />, | 2315 | emptyStateMessage: "Press Enter to create this folder and add it as a project.", |
| 2121 | } | 2316 | } |
| 2122 | : {})} | 2317 | : threadSearch.isPending |
| 2123 | onKeyDown={handleKeyDown} | 2318 | ? { emptyStateMessage: "Searching thread messages…" } |
| 2124 | /> | 2319 | : {})} |
| 2125 | {addProjectCloneFlow?.step === "repository" ? ( | 2320 | /> |
| 2126 | <Tooltip> | 2321 | </CommandPaletteContent> |
| 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> | ||
| 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,6 +26,10 @@ interface FileBrowserPanelProps { |
| 26 | environmentId: EnvironmentId; | 26 | environmentId: EnvironmentId; |
| 27 | cwd: string; | 27 | cwd: string; |
| 28 | projectName: string; | 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 | onOpenFile: (relativePath: string) => void; | 33 | onOpenFile: (relativePath: string) => void; |
| 30 | } | 34 | } |
| 31 | 35 | ||
| ... | @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ | ... | @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ |
| 98 | environmentId, | 102 | environmentId, |
| 99 | cwd, | 103 | cwd, |
| 100 | projectName, | 104 | projectName, |
| 105 | selectedPath, | ||
| 106 | selectedPathRevealId, | ||
| 101 | onOpenFile, | 107 | onOpenFile, |
| 102 | }: FileBrowserPanelProps) { | 108 | }: FileBrowserPanelProps) { |
| 103 | const { resolvedTheme } = useTheme(); | 109 | const { resolvedTheme } = useTheme(); |
| ... | @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ | ... | @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ |
| 111 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); | 117 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); |
| 112 | const treePaths = useMemo(() => entries.map(treePath), [entries]); | 118 | const treePaths = useMemo(() => entries.map(treePath), [entries]); |
| 113 | const previousTreePathsRef = useRef<readonly string[]>([]); | 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 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so | 124 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so |
| 116 | // capture the right-click position ourselves; contextmenu is a composed | 125 | // capture the right-click position ourselves; contextmenu is a composed |
| ... | @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ | ... | @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ |
| 216 | initialExpansion: 1, | 225 | initialExpansion: 1, |
| 217 | icons: T3_PIERRE_ICONS, | 226 | icons: T3_PIERRE_ICONS, |
| 218 | onSelectionChange: (selectedPaths) => { | 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 | dragMention.handleSelectionChange(selectedPaths); | 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 | // Starting a drag selects the dragged row; that selection is a side | 234 | // Starting a drag selects the dragged row; that selection is a side |
| 221 | // effect of the gesture, not a request to open the file. | 235 | // effect of the gesture, not a request to open the file. |
| 222 | if (dragMention.isDragInProgress()) { | 236 | if (dragMention.isDragInProgress()) { |
| ... | @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ | ... | @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ |
| 224 | } | 238 | } |
| 225 | const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); | 239 | const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); |
| 226 | if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { | 240 | if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { |
| 241 | treeSelectionPathRef.current = selectedPath; | ||
| 227 | onOpenFile(selectedPath); | 242 | onOpenFile(selectedPath); |
| 228 | } | 243 | } |
| 229 | }, | 244 | }, |
| ... | @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ | ... | @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ |
| 247 | model.resetPaths(treePaths); | 262 | model.resetPaths(treePaths); |
| 248 | }, [entryKinds, model, treePaths]); | 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 | // Tag tree drags with the composer mention payload. The row is read from | 322 | // Tag tree drags with the composer mention payload. The row is read from |
| 251 | // the composed event path (the tree's shadow root is open), so this does | 323 | // the composed event path (the tree's shadow root is open), so this does |
| 252 | // not depend on running after the tree's own dragstart handler; the drag | 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,6 +51,7 @@ import { |
| 51 | remapFileCommentAnnotations, | 51 | remapFileCommentAnnotations, |
| 52 | } from "./fileCommentAnnotations"; | 52 | } from "./fileCommentAnnotations"; |
| 53 | import { installFileEditorDismissal } from "./fileEditorDismissal"; | 53 | import { installFileEditorDismissal } from "./fileEditorDismissal"; |
| 54 | import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; | ||
| 54 | import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; | 55 | import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; |
| 55 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; | 56 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 56 | import { fileBreadcrumbs } from "./filePath"; | 57 | import { fileBreadcrumbs } from "./filePath"; |
| ... | @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): | ... | @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): |
| 182 | ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); | 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 | function useFileLineReveal( | 207 | function useFileLineReveal( |
| 186 | relativePath: string | null, | 208 | relativePath: string | null, |
| 187 | revealLine: number | null, | 209 | revealLine: number | null, |
| 188 | revealRequestId: number, | 210 | revealRequestId: number, |
| 189 | ): FilePostRender { | 211 | ): FilePostRender { |
| 190 | const [handledRequestIdsByPath] = useState(() => new Map<string, number>()); | 212 | const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>()); |
| 191 | const [latestRequestIdsByPath] = useState(() => new Map<string, number>()); | ||
| 192 | const [pendingFramesByPath] = useState(() => new Map<string, number>()); | ||
| 193 | 213 | ||
| 194 | return useCallback<FilePostRender>( | 214 | return useCallback<FilePostRender>( |
| 195 | (fileContainer, instance, phase) => { | 215 | (fileContainer, instance, phase) => { |
| 196 | if (relativePath === null) return; | 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 | const cancelPendingReveal = () => { | 227 | const cancelPendingReveal = () => { |
| 199 | const frameId = pendingFramesByPath.get(relativePath); | 228 | if (state.frameId !== null) { |
| 200 | if (frameId !== undefined) { | 229 | cancelAnimationFrame(state.frameId); |
| 201 | cancelAnimationFrame(frameId); | 230 | state.frameId = null; |
| 202 | pendingFramesByPath.delete(relativePath); | ||
| 203 | } | 231 | } |
| 232 | state.cancelGuard?.(); | ||
| 204 | }; | 233 | }; |
| 205 | 234 | ||
| 206 | if (phase === "unmount") { | 235 | if (phase === "unmount") { |
| ... | @@ -208,18 +237,20 @@ function useFileLineReveal( | ... | @@ -208,18 +237,20 @@ function useFileLineReveal( |
| 208 | return; | 237 | return; |
| 209 | } | 238 | } |
| 210 | 239 | ||
| 240 | const contents = instance.file?.contents; | ||
| 211 | const targetLine = | 241 | const targetLine = |
| 212 | revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); | 242 | revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); |
| 213 | updateFileLinkReveal(fileContainer, targetLine); | 243 | updateFileLinkReveal(fileContainer, targetLine); |
| 214 | 244 | ||
| 215 | if (!(instance instanceof VirtualizedFile)) return; | 245 | if (!(instance instanceof VirtualizedFile)) return; |
| 216 | 246 | ||
| 217 | if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { | 247 | if (state.latestRequestId !== revealRequestId) { |
| 218 | cancelPendingReveal(); | 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 | fileContainer.style.minHeight = ""; | 254 | fileContainer.style.minHeight = ""; |
| 224 | return; | 255 | return; |
| 225 | } | 256 | } |
| ... | @@ -230,54 +261,113 @@ function useFileLineReveal( | ... | @@ -230,54 +261,113 @@ function useFileLineReveal( |
| 230 | Math.max(instance.height, scrollContainer.clientHeight), | 261 | Math.max(instance.height, scrollContainer.clientHeight), |
| 231 | )}px`; | 262 | )}px`; |
| 232 | 263 | ||
| 233 | if ( | 264 | if (state.handledRequestId === revealRequestId || state.frameId !== null) { |
| 234 | handledRequestIdsByPath.get(relativePath) === revealRequestId || | ||
| 235 | pendingFramesByPath.has(relativePath) | ||
| 236 | ) { | ||
| 237 | return; | 265 | return; |
| 238 | } | 266 | } |
| 239 | 267 | ||
| 240 | const reveal = () => { | 268 | const resolveScrollTarget = (line: number): number | null => { |
| 241 | pendingFramesByPath.delete(relativePath); | 269 | const linePosition = instance.getLinePosition(line); |
| 242 | if ( | 270 | if (!linePosition) return null; |
| 243 | latestRequestIdsByPath.get(relativePath) !== revealRequestId || | ||
| 244 | !fileContainer.isConnected | ||
| 245 | ) { | ||
| 246 | return; | ||
| 247 | } | ||
| 248 | |||
| 249 | const linePosition = instance.getLinePosition(targetLine); | ||
| 250 | if (!linePosition) return; | ||
| 251 | 271 | ||
| 272 | const scrollContainerRect = scrollContainer.getBoundingClientRect(); | ||
| 252 | const fileTop = | 273 | const fileTop = |
| 253 | scrollContainer.scrollTop + | 274 | scrollContainer.scrollTop + |
| 254 | fileContainer.getBoundingClientRect().top - | 275 | fileContainer.getBoundingClientRect().top - |
| 255 | scrollContainer.getBoundingClientRect().top; | 276 | scrollContainerRect.top; |
| 256 | const centeredTop = Math.max( | 277 | const root = fileContainer.shadowRoot ?? fileContainer; |
| 257 | 0, | 278 | const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`); |
| 258 | fileTop + | 279 | const renderedLineRect = renderedLineElement?.getBoundingClientRect(); |
| 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 | ); | ||
| 266 | 280 | ||
| 267 | scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); | 281 | return resolveCenteredFileLineScrollTop({ |
| 268 | handledRequestIdsByPath.set(relativePath, revealRequestId); | 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 | [ | 370 | [revealStatesByPath, relativePath, revealLine, revealRequestId], |
| 274 | handledRequestIdsByPath, | ||
| 275 | latestRequestIdsByPath, | ||
| 276 | pendingFramesByPath, | ||
| 277 | relativePath, | ||
| 278 | revealLine, | ||
| 279 | revealRequestId, | ||
| 280 | ], | ||
| 281 | ); | 371 | ); |
| 282 | } | 372 | } |
| 283 | 373 | ||
| ... | @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ | ... | @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ |
| 963 | environmentId={environmentId} | 1053 | environmentId={environmentId} |
| 964 | cwd={cwd} | 1054 | cwd={cwd} |
| 965 | projectName={projectName} | 1055 | projectName={projectName} |
| 1056 | selectedPath={relativePath} | ||
| 1057 | selectedPathRevealId={revealRequestId} | ||
| 966 | onOpenFile={onOpenFile} | 1058 | onOpenFile={onOpenFile} |
| 967 | /> | 1059 | /> |
| 968 | </aside> | 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,6 +11,7 @@ import { useCallback } from "react"; |
| 11 | 11 | ||
| 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 13 | import { projectEnvironment } from "~/state/projects"; | 13 | import { projectEnvironment } from "~/state/projects"; |
| 14 | import { useProjectPathSearch } from "~/state/queries"; | ||
| 14 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | 15 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 15 | 16 | ||
| 16 | const EMPTY_PROJECT_FILE_PATH = ""; | 17 | const EMPTY_PROJECT_FILE_PATH = ""; |
| ... | @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( | ... | @@ -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 | export function useProjectFileQuery( | 166 | export function useProjectFileQuery( |
| 140 | environmentId: EnvironmentId, | 167 | environmentId: EnvironmentId, |
| 141 | cwd: string, | 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,7 +190,7 @@ export function useNewThreadHandler() { |
| 190 | reusableStoredDraftThread.draftId, | 190 | reusableStoredDraftThread.draftId, |
| 191 | { | 191 | { |
| 192 | threadId: reusableStoredDraftThread.threadId, | 192 | threadId: reusableStoredDraftThread.threadId, |
| 193 | ...(workspaceContext ?? {}), | 193 | ...workspaceContext, |
| 194 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | 194 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 195 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | 195 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 196 | }, | 196 | }, |
apps/web/src/keybindings.test.ts+52| ... | @@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ |
| 118 | command: "commandPalette.toggle", | 118 | command: "commandPalette.toggle", |
| 119 | whenAst: whenNot(whenIdentifier("terminalFocus")), | 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 | shortcut: modShortcut("m", { shiftKey: true }), | 132 | shortcut: modShortcut("m", { shiftKey: true }), |
| 123 | command: "modelPicker.toggle", | 133 | command: "modelPicker.toggle", |
| ... | @@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => { | ... | @@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => { |
| 327 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), | 337 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), |
| 328 | "⌘K", | 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 | assert.strictEqual( | 348 | assert.strictEqual( |
| 331 | shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), | 349 | shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), |
| 332 | "Ctrl+Shift+M", | 350 | "Ctrl+Shift+M", |
| ... | @@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => { | ... | @@ -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 | it("matches diff.toggle shortcut outside terminal focus", () => { | 569 | it("matches diff.toggle shortcut outside terminal focus", () => { |
| 518 | assert.isTrue( | 570 | assert.isTrue( |
| 519 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | 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,11 +1,24 @@ |
| 1 | import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; | 1 | import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; |
| 2 | import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; | 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 | import { environmentCatalog } from "../connection/catalog"; | 6 | import { environmentCatalog } from "../connection/catalog"; |
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 6 | import { environmentSnapshotAtom } from "./shell"; | 8 | import { environmentSnapshotAtom } from "./shell"; |
| 7 | 9 | ||
| 8 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | 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 | export const environmentProjects = createEnvironmentProjectAtoms({ | 22 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 10 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 23 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 11 | snapshotAtom: environmentSnapshotAtom, | 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,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 12 | import type { | 12 | import type { |
| 13 | EnvironmentId, | 13 | EnvironmentId, |
| 14 | OrchestrationThread, | 14 | OrchestrationThread, |
| 15 | ProjectContentMatch, | ||
| 16 | ProjectEntryKind, | ||
| 15 | ThreadId, | 17 | ThreadId, |
| 16 | VcsListRefsResult, | 18 | VcsListRefsResult, |
| 17 | VcsRef, | 19 | VcsRef, |
| ... | @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; | ... | @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; |
| 24 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 26 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 25 | import { orchestrationEnvironment } from "./orchestration"; | 27 | import { orchestrationEnvironment } from "./orchestration"; |
| 26 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; | 28 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; |
| 27 | import { projectEnvironment } from "./projects"; | 29 | import { projectContentSearch, projectEnvironment } from "./projects"; |
| 28 | import { useEnvironmentQuery } from "./query"; | 30 | import { useEnvironmentQuery } from "./query"; |
| 29 | import { useEnvironmentThread } from "./threads"; | 31 | import { useEnvironmentThread } from "./threads"; |
| 30 | import { vcsEnvironment } from "./vcs"; | 32 | import { vcsEnvironment } from "./vcs"; |
| 31 | 33 | ||
| 32 | const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | 34 | const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; |
| 33 | const COMPOSER_PATH_SEARCH_LIMIT = 80; | 35 | const COMPOSER_PATH_SEARCH_LIMIT = 80; |
| 36 | const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; | ||
| 37 | const PROJECT_CONTENT_SEARCH_LIMIT = 500; | ||
| 34 | const THREAD_SEARCH_DEBOUNCE_MS = 200; | 38 | const THREAD_SEARCH_DEBOUNCE_MS = 200; |
| 35 | const VCS_REF_LIST_LIMIT = 100; | 39 | const VCS_REF_LIST_LIMIT = 100; |
| 36 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | 40 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; |
| 41 | const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = []; | ||
| 37 | const INITIAL_BRANCH_CURSORS = [undefined] as const; | 42 | const INITIAL_BRANCH_CURSORS = [undefined] as const; |
| 38 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); | 43 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); |
| 39 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ | 44 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ |
| ... | @@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { | ... | @@ -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 | const normalizedTarget = useMemo( | 259 | const normalizedTarget = useMemo( |
| 234 | () => ({ | 260 | () => ({ |
| 235 | environmentId: target.environmentId, | 261 | environmentId: target.environmentId, |
| 236 | cwd: target.cwd, | 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 | const result = useEnvironmentQuery( | 269 | const result = useEnvironmentQuery( |
| 243 | debouncedTarget.environmentId !== null && | 270 | debouncedTarget.environmentId !== null && |
| 244 | debouncedTarget.cwd !== null && | 271 | debouncedTarget.cwd !== null && |
| 245 | debouncedTarget.query.length > 0 | 272 | debouncedTarget.query !== null && |
| 273 | (allowEmptyQuery || debouncedTarget.query.length > 0) | ||
| 246 | ? projectEnvironment.searchEntries({ | 274 | ? projectEnvironment.searchEntries({ |
| 247 | environmentId: debouncedTarget.environmentId, | 275 | environmentId: debouncedTarget.environmentId, |
| 248 | input: { | 276 | input: { |
| 249 | cwd: debouncedTarget.cwd, | 277 | cwd: debouncedTarget.cwd, |
| 250 | query: debouncedTarget.query, | 278 | query: debouncedTarget.query, |
| 251 | limit: COMPOSER_PATH_SEARCH_LIMIT, | 279 | limit, |
| 280 | ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), | ||
| 252 | }, | 281 | }, |
| 253 | }) | 282 | }) |
| 254 | : null, | 283 | : null, |
| ... | @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { | ... | @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 257 | return { | 286 | return { |
| 258 | entries: result.data?.entries ?? [], | 287 | entries: result.data?.entries ?? [], |
| 259 | error: result.error, | 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 | refresh: result.refresh, | 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 | export function useCheckpointDiff( | 344 | export function useCheckpointDiff( |
| 266 | target: CheckpointDiffTarget, | 345 | target: CheckpointDiffTarget, |
| 267 | options?: { readonly enabled?: boolean }, | 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,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. |
| 37 | Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and | 37 | Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and |
| 38 | `chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`. | 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 | The command palette searches active thread titles, projects, branches, user messages, and final | 44 | The command palette searches active thread titles, projects, branches, user messages, and final |
| 41 | agent responses across connected environments. Message matches show one labeled excerpt while | 45 | agent responses across connected environments. Message matches show one labeled excerpt while |
| 42 | keeping the thread's project, branch, and machine context visible. Message search begins after two | 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,6 +59,18 @@ it.effect("parses keybinding rules", () => |
| 59 | }); | 59 | }); |
| 60 | assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); | 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 | const parsedLocal = yield* decode(KeybindingRule, { | 74 | const parsedLocal = yield* decode(KeybindingRule, { |
| 63 | key: "mod+shift+n", | 75 | key: "mod+shift+n", |
| 64 | command: "chat.newLocal", | 76 | command: "chat.newLocal", |
packages/contracts/src/keybindings.ts+2| ... | @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 63 | "preview.zoomOut", | 63 | "preview.zoomOut", |
| 64 | "preview.resetZoom", | 64 | "preview.resetZoom", |
| 65 | "commandPalette.toggle", | 65 | "commandPalette.toggle", |
| 66 | "filePicker.toggle", | ||
| 67 | "projectSearch.toggle", | ||
| 66 | "composer.stash", | 68 | "composer.stash", |
| 67 | "chat.new", | 69 | "chat.new", |
| 68 | "chat.newLocal", | 70 | "chat.newLocal", |
packages/contracts/src/project.test.ts+42| ... | @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ProjectReadFileError, | 5 | ProjectReadFileError, |
| 6 | ProjectSearchContentsError, | ||
| 7 | ProjectSearchContentsInput, | ||
| 6 | ProjectSearchEntriesError, | 8 | ProjectSearchEntriesError, |
| 9 | ProjectSearchEntriesInput, | ||
| 7 | ProjectWriteFileError, | 10 | ProjectWriteFileError, |
| 8 | } from "./project.ts"; | 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 | describe("project RPC errors", () => { | 40 | describe("project RPC errors", () => { |
| 11 | it("derives stable messages from structured request context while retaining causes", () => { | 41 | it("derives stable messages from structured request context while retaining causes", () => { |
| 12 | const cause = new Error("sensitive platform detail"); | 42 | const cause = new Error("sensitive platform detail"); |
| ... | @@ -39,6 +69,18 @@ describe("project RPC errors", () => { | ... | @@ -39,6 +69,18 @@ describe("project RPC errors", () => { |
| 39 | expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); | 69 | expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); |
| 40 | expect(readError.message).not.toContain(cause.message); | 70 | expect(readError.message).not.toContain(cause.message); |
| 41 | expect(readError.cause).toBe(cause); | 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 | it("decodes legacy message-only errors during rolling upgrades", () => { | 86 | it("decodes legacy message-only errors during rolling upgrades", () => { |
packages/contracts/src/project.ts+78-4| ... | @@ -1,19 +1,29 @@ | ... | @@ -1,19 +1,29 @@ |
| 1 | import * as Schema from "effect/Schema"; | 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 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | 9 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 10 | const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; | ||
| 5 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; | 11 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; |
| 6 | const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; | 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 | export const ProjectSearchEntriesInput = Schema.Struct({ | 17 | export const ProjectSearchEntriesInput = Schema.Struct({ |
| 9 | cwd: TrimmedNonEmptyString, | 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 | limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), | 22 | limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), |
| 23 | kind: Schema.optional(ProjectEntryKind), | ||
| 12 | }); | 24 | }); |
| 13 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; | 25 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; |
| 14 | 26 | ||
| 15 | const ProjectEntryKind = Schema.Literals(["file", "directory"]); | ||
| 16 | |||
| 17 | export const ProjectEntry = Schema.Struct({ | 27 | export const ProjectEntry = Schema.Struct({ |
| 18 | path: TrimmedNonEmptyString, | 28 | path: TrimmedNonEmptyString, |
| 19 | kind: ProjectEntryKind, | 29 | kind: ProjectEntryKind, |
| ... | @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ | ... | @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ |
| 26 | }); | 36 | }); |
| 27 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; | 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 | export const ProjectListEntriesInput = Schema.Struct({ | 72 | export const ProjectListEntriesInput = Schema.Struct({ |
| 30 | cwd: TrimmedNonEmptyString, | 73 | cwd: TrimmedNonEmptyString, |
| 31 | }); | 74 | }); |
| ... | @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe | ... | @@ -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 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( | 171 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( |
| 98 | "ProjectListEntriesError", | 172 | "ProjectListEntriesError", |
| 99 | { | 173 | { |
packages/contracts/src/rpc.ts+11| ... | @@ -76,6 +76,9 @@ import { | ... | @@ -76,6 +76,9 @@ import { |
| 76 | ProjectReadFileError, | 76 | ProjectReadFileError, |
| 77 | ProjectReadFileInput, | 77 | ProjectReadFileInput, |
| 78 | ProjectReadFileResult, | 78 | ProjectReadFileResult, |
| 79 | ProjectSearchContentsError, | ||
| 80 | ProjectSearchContentsInput, | ||
| 81 | ProjectSearchContentsResult, | ||
| 79 | ProjectSearchEntriesError, | 82 | ProjectSearchEntriesError, |
| 80 | ProjectSearchEntriesInput, | 83 | ProjectSearchEntriesInput, |
| 81 | ProjectSearchEntriesResult, | 84 | ProjectSearchEntriesResult, |
| ... | @@ -166,6 +169,7 @@ export const WS_METHODS = { | ... | @@ -166,6 +169,7 @@ export const WS_METHODS = { |
| 166 | projectsRemove: "projects.remove", | 169 | projectsRemove: "projects.remove", |
| 167 | projectsListEntries: "projects.listEntries", | 170 | projectsListEntries: "projects.listEntries", |
| 168 | projectsReadFile: "projects.readFile", | 171 | projectsReadFile: "projects.readFile", |
| 172 | projectsSearchContents: "projects.searchContents", | ||
| 169 | projectsSearchEntries: "projects.searchEntries", | 173 | projectsSearchEntries: "projects.searchEntries", |
| 170 | projectsWriteFile: "projects.writeFile", | 174 | projectsWriteFile: "projects.writeFile", |
| 171 | 175 | ||
| ... | @@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr | ... | @@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr |
| 438 | error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), | 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 | export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { | 451 | export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { |
| 442 | payload: ProjectListEntriesInput, | 452 | payload: ProjectListEntriesInput, |
| 443 | success: ProjectListEntriesResult, | 453 | success: ProjectListEntriesResult, |
| ... | @@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 801 | WsSourceControlPublishRepositoryRpc, | 811 | WsSourceControlPublishRepositoryRpc, |
| 802 | WsProjectsListEntriesRpc, | 812 | WsProjectsListEntriesRpc, |
| 803 | WsProjectsReadFileRpc, | 813 | WsProjectsReadFileRpc, |
| 814 | WsProjectsSearchContentsRpc, | ||
| 804 | WsProjectsSearchEntriesRpc, | 815 | WsProjectsSearchEntriesRpc, |
| 805 | WsProjectsWriteFileRpc, | 816 | WsProjectsWriteFileRpc, |
| 806 | WsShellOpenInEditorRpc, | 817 | WsShellOpenInEditorRpc, |
packages/shared/src/keybindings.ts+2| ... | @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 35 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, | 35 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, |
| 36 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, | 36 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, |
| 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | 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 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, | 40 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, |
| 39 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | 41 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 40 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 42 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |