From 1ea8ce458f967ac3a0b1d0c01c072ed907405dc3 Mon Sep 17 00:00:00 2001 From: Rereyuop <1622641958@qq.com> Date: Mon, 27 Jul 2026 12:03:22 +0800 Subject: [PATCH] feat: add tabs and Finder integration Add independent search tabs with keyboard and menu controls.\n\nProject matching results into an expandable Finder-style hierarchy, with a flat-list fallback for large or unavailable result metadata.\n\nBundle a macOS Finder Sync extension and handle cardinal:// deep links so Finder selections can open as new searches. Update the search bar, translations, dependencies, build configuration, and documentation to support the new workflow. --- .cargo/config.toml | 3 + .gitignore | 4 +- CUSTOM_CHANGES.md | 58 ++ cardinal/package-lock.json | 22 +- cardinal/package.json | 6 +- cardinal/src-tauri/Cargo.lock | 103 ++++ cardinal/src-tauri/Cargo.toml | 1 + cardinal/src-tauri/capabilities/default.json | 1 + .../finder-sync/CardinalFinderSync.m | 49 ++ cardinal/src-tauri/finder-sync/Info.plist | 39 ++ cardinal/src-tauri/finder-sync/build.sh | 24 + .../src-tauri/finder-sync/entitlements.plist | 8 + cardinal/src-tauri/finder-sync/main.m | 9 + cardinal/src-tauri/src/lib.rs | 1 + cardinal/src-tauri/tauri.conf.json | 12 +- cardinal/src/App.css | 179 +++++- cardinal/src/App.tsx | 478 +--------------- cardinal/src/components/FileRow.tsx | 35 +- cardinal/src/components/SearchBar.tsx | 58 +- cardinal/src/components/TabContent.tsx | 511 ++++++++++++++++++ cardinal/src/components/TabManager.tsx | 281 ++++++++++ .../components/__tests__/SearchBar.test.tsx | 42 +- cardinal/src/constants/appEvents.ts | 1 + cardinal/src/hooks/useAppHotkeys.ts | 9 +- cardinal/src/hooks/useAppWindowListeners.ts | 11 +- cardinal/src/hooks/useFilesTabEffects.ts | 23 +- cardinal/src/hooks/useHierarchicalResults.ts | 203 +++++++ cardinal/src/hooks/useQuickLook.ts | 8 +- cardinal/src/i18n/resources/ar-SA.json | 7 +- cardinal/src/i18n/resources/de-DE.json | 7 +- cardinal/src/i18n/resources/en-US.json | 7 +- cardinal/src/i18n/resources/es-ES.json | 7 +- cardinal/src/i18n/resources/fr-FR.json | 7 +- cardinal/src/i18n/resources/hi-IN.json | 7 +- cardinal/src/i18n/resources/it-IT.json | 7 +- cardinal/src/i18n/resources/ja-JP.json | 7 +- cardinal/src/i18n/resources/ko-KR.json | 7 +- cardinal/src/i18n/resources/pt-BR.json | 7 +- cardinal/src/i18n/resources/ru-RU.json | 7 +- cardinal/src/i18n/resources/tr-TR.json | 7 +- cardinal/src/i18n/resources/uk-UA.json | 7 +- cardinal/src/i18n/resources/zh-CN.json | 7 +- cardinal/src/i18n/resources/zh-TW.json | 7 +- cardinal/src/menu.ts | 11 +- cardinal/src/runtime/tauriEventRuntime.ts | 56 ++ tools/clang-rust-lld.sh | 8 + 46 files changed, 1795 insertions(+), 564 deletions(-) create mode 100644 CUSTOM_CHANGES.md create mode 100644 cardinal/src-tauri/finder-sync/CardinalFinderSync.m create mode 100644 cardinal/src-tauri/finder-sync/Info.plist create mode 100644 cardinal/src-tauri/finder-sync/build.sh create mode 100644 cardinal/src-tauri/finder-sync/entitlements.plist create mode 100644 cardinal/src-tauri/finder-sync/main.m create mode 100644 cardinal/src/components/TabContent.tsx create mode 100644 cardinal/src/components/TabManager.tsx create mode 100644 cardinal/src/hooks/useHierarchicalResults.ts create mode 100755 tools/clang-rust-lld.sh diff --git a/.cargo/config.toml b/.cargo/config.toml index 8857c875..2596cb36 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,6 +7,9 @@ rustflags = [ # "-Cpanic=abort", ] +[target.aarch64-apple-darwin] +linker = "tools/clang-rust-lld.sh" + [unstable] # -- currently disabled for test # https://doc.rust-lang.org/cargo/reference/unstable.html#build-std diff --git a/.gitignore b/.gitignore index fd531785..80039394 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ target +cardinal/src-tauri/finder-sync/build/ +proc_macro_probe .DS_Store -.vscode \ No newline at end of file +.vscode diff --git a/CUSTOM_CHANGES.md b/CUSTOM_CHANGES.md new file mode 100644 index 00000000..9b3d8ea4 --- /dev/null +++ b/CUSTOM_CHANGES.md @@ -0,0 +1,58 @@ +# 自定义分支改动说明 + +本文档记录 `my-custom` 分支相对于上游 Cardinal 的主要定制内容。 + +## 多搜索标签页 + +- 搜索界面支持同时打开多个标签页,每个标签页独立维护查询、结果和交互状态。 +- 点击标签栏中的 `+` 或按 `Command + T` 可以新建搜索标签页。 +- 点击标签页的关闭按钮、按 `Command + W`,或使用“窗口 > 关闭标签页”菜单可以关闭当前标签页。 +- 关闭最后一个标签页时会自动创建一个新的空白搜索标签页。 +- 标签页标题会随当前搜索内容更新。 +- 偏好设置和全磁盘访问权限提示仍由所有标签页共享。 + +## 层级搜索结果 + +- 当搜索结果中的目录同时也是其他匹配项的祖先目录时,结果会按 Finder 风格显示为可展开的层级结构。 +- 目录行提供展开和折叠控件,并通过缩进表示文件层级。 +- 为避免大型结果集产生额外的元数据读取和树构建开销,结果超过 20,000 条时继续使用原有的扁平列表。 +- 无法读取节点信息时会自动回退到扁平列表。 + +## Finder 右键搜索 + +macOS Finder Sync 扩展为 Finder 选中项增加“在 Cardinal 中搜索”右键菜单。 + +操作流程: + +1. 在 Finder 中选中文件或目录。 +2. 从右键菜单选择“在 Cardinal 中搜索”。 +3. 扩展移除文件扩展名,并通过 `cardinal://finder-search?query=...` 深层链接唤起 Cardinal。 +4. Cardinal 为该查询创建并激活一个新的搜索标签页。 + +相关实现包括: + +- `cardinal/src-tauri/finder-sync/`:Finder Sync 扩展源码、权限配置和构建脚本。 +- `cardinal/src/runtime/tauriEventRuntime.ts`:深层链接接收与分发。 +- `cardinal/src/components/TabManager.tsx`:根据 Finder 请求创建标签页。 + +## 搜索栏调整 + +- 搜索文本框和目录范围输入框使用标签页独立的元素 ID,避免多标签页下的 ID 冲突。 +- 调整目录范围控件的位置、方向键切换焦点的行为和视觉样式。 +- 更新相关测试与所有现有语言资源中的标签页文案。 + +## macOS 构建说明 + +- 最低支持版本为 macOS 12。 +- `npm run tauri ...` 会先运行 `npm run build:finder-sync` 构建 Finder Sync 扩展。 +- Finder Sync 扩展会被打包到应用的 `PlugIns/CardinalFinderSync.appex`。 +- 当前扩展构建脚本和 Cargo linker 配置面向 `aarch64-apple-darwin`。 +- 扩展使用临时签名;发布构建需要根据实际发行证书和签名流程调整。 +- Finder Sync 构建输出位于 `cardinal/src-tauri/finder-sync/build/`,该目录不会提交到 Git。 + +## 主要依赖与配置 + +- 增加 `@tauri-apps/plugin-deep-link` 和对应的 Rust/Tauri 配置。 +- 注册 `cardinal` URL scheme,并授予深层链接 capability。 +- 增加 `tools/clang-rust-lld.sh`,供 Apple Silicon Rust 构建选择对应的 linker。 + diff --git a/cardinal/package-lock.json b/cardinal/package-lock.json index 597030bf..462f66e2 100644 --- a/cardinal/package-lock.json +++ b/cardinal/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "~2.10.0", + "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", @@ -1574,6 +1575,25 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", + "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-deep-link/node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, "node_modules/@tauri-apps/plugin-global-shortcut": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", diff --git a/cardinal/package.json b/cardinal/package.json index e5fe8f2d..839a9533 100644 --- a/cardinal/package.json +++ b/cardinal/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "tauri": "tauri", + "build:finder-sync": "sh ./src-tauri/finder-sync/build.sh", + "tauri": "npm run build:finder-sync && tauri", "test": "vitest --watch=false", "test:watch": "vitest --watch", "typecheck": "tsc --noEmit", @@ -16,7 +17,8 @@ }, "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "~2.10.0", + "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index e19f1ca0..cef8657c 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -404,6 +404,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-drag", "tauri-plugin-global-shortcut", "tauri-plugin-macos-permissions", @@ -578,6 +579,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -709,6 +730,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -924,6 +951,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1689,6 +1725,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2884,6 +2926,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3589,6 +3641,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4404,6 +4466,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-drag" version = "2.1.0" @@ -4723,6 +4806,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5681,6 +5773,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index 386b1944..8f00fbc8 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ parking_lot = "0.12" tauri-plugin-prevent-default = "4" tauri-plugin-macos-permissions = "2.3.0" tauri-plugin-window-state = "2" +tauri-plugin-deep-link = "2" once_cell = { version = "1.20", features = ["parking_lot"] } cardinal-sdk.path = "../../cardinal-sdk" diff --git a/cardinal/src-tauri/capabilities/default.json b/cardinal/src-tauri/capabilities/default.json index ce8f857a..dfcaec2f 100644 --- a/cardinal/src-tauri/capabilities/default.json +++ b/cardinal/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "opener:default", "macos-permissions:default", "drag:default", + "deep-link:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister" diff --git a/cardinal/src-tauri/finder-sync/CardinalFinderSync.m b/cardinal/src-tauri/finder-sync/CardinalFinderSync.m new file mode 100644 index 00000000..fcfc79f4 --- /dev/null +++ b/cardinal/src-tauri/finder-sync/CardinalFinderSync.m @@ -0,0 +1,49 @@ +#import +#import + +@interface CardinalFinderSync : FIFinderSync +@end + +@implementation CardinalFinderSync + +- (instancetype)init { + self = [super init]; + if (self) { + [FIFinderSyncController defaultController].directoryURLs = + [NSSet setWithObject:[NSURL fileURLWithPath:@"/"]]; + } + return self; +} + +- (NSMenu *)menuForMenuKind:(FIMenuKind)menuKind { + if (menuKind != FIMenuKindContextualMenuForItems) { + return nil; + } + + NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Cardinal"]; + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"在 Cardinal 中搜索" + action:@selector(searchInCardinal:) + keyEquivalent:@""]; + item.target = self; + [menu addItem:item]; + return menu; +} + +- (void)searchInCardinal:(id)sender { + FIFinderSyncController *controller = [FIFinderSyncController defaultController]; + NSURL *selectedURL = controller.selectedItemURLs.firstObject; + NSString *filename = selectedURL.lastPathComponent.stringByDeletingPathExtension; + if (filename.length == 0) { + return; + } + + NSURLComponents *components = [[NSURLComponents alloc] init]; + components.scheme = @"cardinal"; + components.host = @"finder-search"; + components.queryItems = @[[NSURLQueryItem queryItemWithName:@"query" value:filename]]; + if (components.URL) { + [[NSWorkspace sharedWorkspace] openURL:components.URL]; + } +} + +@end diff --git a/cardinal/src-tauri/finder-sync/Info.plist b/cardinal/src-tauri/finder-sync/Info.plist new file mode 100644 index 00000000..f9b07ee9 --- /dev/null +++ b/cardinal/src-tauri/finder-sync/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Cardinal Finder Search + CFBundleExecutable + CardinalFinderSync + CFBundleIdentifier + com.cardinal.one.findersync + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CardinalFinderSync + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.23 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + NSExtension + + NSExtensionAttributes + + NSExtensionPointIdentifier + com.apple.FinderSync + NSExtensionPrincipalClass + CardinalFinderSync + + + diff --git a/cardinal/src-tauri/finder-sync/build.sh b/cardinal/src-tauri/finder-sync/build.sh new file mode 100644 index 00000000..469baa43 --- /dev/null +++ b/cardinal/src-tauri/finder-sync/build.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +OUTPUT_DIR="$SCRIPT_DIR/build/CardinalFinderSync.appex" +CONTENTS_DIR="$OUTPUT_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RUST_SYSROOT=$(rustc --print sysroot) +LINKER="$RUST_SYSROOT/lib/rustlib/aarch64-apple-darwin/bin/gcc-ld/ld64.lld" + +rm -rf "$OUTPUT_DIR" +mkdir -p "$MACOS_DIR" + +xcrun clang "-fuse-ld=$LINKER" -mmacosx-version-min=12.0 \ + -fobjc-arc -framework Cocoa -framework FinderSync \ + "$SCRIPT_DIR/main.m" \ + "$SCRIPT_DIR/CardinalFinderSync.m" \ + -o "$MACOS_DIR/CardinalFinderSync" + +cp "$SCRIPT_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" +codesign --force --sign - --options runtime \ + --entitlements "$SCRIPT_DIR/entitlements.plist" \ + --identifier com.cardinal.one.findersync \ + "$OUTPUT_DIR" diff --git a/cardinal/src-tauri/finder-sync/entitlements.plist b/cardinal/src-tauri/finder-sync/entitlements.plist new file mode 100644 index 00000000..1b44cd3c --- /dev/null +++ b/cardinal/src-tauri/finder-sync/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/cardinal/src-tauri/finder-sync/main.m b/cardinal/src-tauri/finder-sync/main.m new file mode 100644 index 00000000..cc77fae6 --- /dev/null +++ b/cardinal/src-tauri/finder-sync/main.m @@ -0,0 +1,9 @@ +#import + +// NSExtensionMain is provided by the macOS extension runtime, but it is not +// declared by the SDK headers used by this build. +extern int NSExtensionMain(int argc, const char *argv[]); + +int main(int argc, const char * argv[]) { + return NSExtensionMain(argc, argv); +} diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 920840c0..7a80e21d 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -79,6 +79,7 @@ pub fn run() -> Result<()> { builder = builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_drag::init()) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) diff --git a/cardinal/src-tauri/tauri.conf.json b/cardinal/src-tauri/tauri.conf.json index 6b674bef..3af6d198 100644 --- a/cardinal/src-tauri/tauri.conf.json +++ b/cardinal/src-tauri/tauri.conf.json @@ -21,13 +21,23 @@ "csp": null } }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["cardinal"] + } + } + }, "bundle": { "active": true, "targets": "all", "category": "Utilities", "macOS": { "signingIdentity": "-", - "minimumSystemVersion": "12.0" + "minimumSystemVersion": "12.0", + "files": { + "PlugIns/CardinalFinderSync.appex": "./finder-sync/build/CardinalFinderSync.appex" + } }, "icon": [ "icons/32x32.png", diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 5d6af384..4254a06c 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -108,13 +108,140 @@ main, height: 100%; } +.tab-manager { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + overflow: hidden; + background: var(--color-bg); +} + +.workspace-tab-bar { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + min-height: 2.25rem; + padding: 0.35rem var(--container-padding) 0; + border-bottom: 1px solid rgba(var(--color-accent-rgb), 0.12); + background: var(--color-elevated-bg); + -webkit-user-select: none; + user-select: none; +} + +.workspace-tab-list { + display: flex; + align-items: stretch; + gap: 0.25rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.workspace-tab-list::-webkit-scrollbar { + display: none; +} + +.workspace-tab { + display: flex; + align-items: center; + flex: 0 0 14rem; + width: 14rem; + min-width: 14rem; + max-width: 14rem; + height: 1.9rem; + overflow: hidden; + border: 1px solid transparent; + border-radius: 8px 8px 0 0; + color: var(--color-muted); + background: transparent; +} + +.workspace-tab:hover { + background: rgba(var(--color-accent-rgb), 0.06); +} + +.workspace-tab.is-active { + border-color: rgba(var(--color-accent-rgb), 0.14); + border-bottom-color: transparent; + color: var(--color-text); + background: var(--color-bg); +} + +.workspace-tab-select, +.workspace-tab-close, +.workspace-tab-add { + height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 6px; + color: inherit; + background: transparent; +} + +.workspace-tab-select { + flex: 1; + min-width: 0; + padding: 0 0.45rem 0 0.65rem; + text-align: left; +} + +.workspace-tab-title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-tab-close { + flex: 0 0 1.55rem; + width: 1.55rem; + font-size: 1rem; + line-height: 1; + opacity: 0.65; +} + +.workspace-tab-close:hover, +.workspace-tab-add:hover { + opacity: 1; + background: rgba(var(--color-accent-rgb), 0.1); +} + +.workspace-tab-add { + flex: 0 0 1.9rem; + width: 1.9rem; + font-size: 1.15rem; + line-height: 1; +} + +.workspace-tab-select:focus-visible, +.workspace-tab-close:focus-visible, +.workspace-tab-add:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +.workspace-tab-panels, +.workspace-tab-panel { + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.workspace-tab-panel[hidden] { + display: none; +} + .container { margin: 0; display: grid; grid-template-rows: auto 1fr auto; /* search bar | scroll area | status bar */ grid-template-columns: 100%; text-align: center; - height: 100vh; + height: 100%; } /* === Search Bar === */ @@ -152,7 +279,7 @@ main, min-width: 6rem; } -#search-input { +.search-input { height: 100%; width: 100%; min-width: 0; @@ -214,7 +341,7 @@ main, pointer-events: auto; } -#directory-scope-input { +.directory-scope-input { height: 100%; width: 100%; min-width: 0; @@ -227,17 +354,17 @@ main, overflow: hidden; } -#directory-scope-input:disabled { +.directory-scope-input:disabled { cursor: default; } -#search-input:focus, -#directory-scope-input:focus { +.search-input:focus, +.directory-scope-input:focus { outline: none; } -#search-input::placeholder, -#directory-scope-input::placeholder { +.search-input::placeholder, +.directory-scope-input::placeholder { color: var(--color-muted); } @@ -395,7 +522,7 @@ main, max-width: 100%; } - #directory-scope-input { + .directory-scope-input { width: 100%; } } @@ -949,6 +1076,40 @@ button:active:not(:disabled) { padding: 0 var(--cell-hpad); } +.disclosure-button, +.disclosure-spacer { + width: 12px; + height: 16px; + flex: 0 0 12px; +} + +.disclosure-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0; + color: var(--color-muted); + background: transparent; + font-size: 8px; + line-height: 1; +} + +.disclosure-button:hover, +.disclosure-button:active:not(:disabled) { + border: 0; + color: currentColor; + background: transparent; +} + +.disclosure-button > span { + transition: transform 100ms ease; +} + +.disclosure-button--expanded > span { + transform: rotate(90deg); +} + .file-icon { width: 16px; height: 16px; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 6a49e9a1..708f9e1f 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -1,479 +1,7 @@ -import { useRef, useCallback, useEffect, useMemo } from 'react'; -import type { ChangeEvent, CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import './App.css'; -import { FileRow } from './components/FileRow'; -import { SearchBar } from './components/SearchBar'; -import { FilesTabContent } from './components/FilesTabContent'; -import { PermissionOverlay } from './components/PermissionOverlay'; -import PreferencesOverlay from './components/PreferencesOverlay'; -import StatusBar from './components/StatusBar'; -import type { SearchResultItem } from './types/search'; -import { useColumnResize } from './hooks/useColumnResize'; -import { useContextMenu } from './hooks/useContextMenu'; -import { useFileSearch } from './hooks/useFileSearch'; -import { useEventColumnWidths } from './hooks/useEventColumnWidths'; -import { useRecentFSEvents } from './hooks/useRecentFSEvents'; -import { DEFAULT_SORTABLE_RESULT_THRESHOLD, useRemoteSort } from './hooks/useRemoteSort'; -import { useSelection } from './hooks/useSelection'; -import { useQuickLook } from './hooks/useQuickLook'; -import { ROW_HEIGHT, OVERSCAN_ROW_COUNT } from './constants'; -import type { VirtualListHandle } from './components/VirtualList'; -import FSEventsPanel from './components/FSEventsPanel'; -import type { FSEventsPanelHandle } from './components/FSEventsPanel'; -import { useTranslation } from 'react-i18next'; -import { useFullDiskAccessPermission } from './hooks/useFullDiskAccessPermission'; -import type { DisplayState } from './components/StateDisplay'; -import { openResultPath } from './utils/openResultPath'; -import { useStableEvent } from './hooks/useStableEvent'; -import { useAppHotkeys } from './hooks/useAppHotkeys'; -import { useAppPreferences } from './hooks/useAppPreferences'; -import { useAppWindowListeners } from './hooks/useAppWindowListeners'; -import { useFilesTabEffects } from './hooks/useFilesTabEffects'; -import { useFilesTabState } from './hooks/useFilesTabState'; +import { TabManager } from './components/TabManager'; -function App() { - const { - state, - searchParams, - updateSearchParams, - queueSearch, - queueDirectorySearch, - queueDirectoryScopeOpen, - handleStatusUpdate, - setLifecycleState, - requestRescan, - } = useFileSearch(); - const { - results, - resultsVersion, - scannedFiles, - processedEvents, - rescanErrors, - currentQuery, - currentDirectoryQuery, - highlightTerms, - showLoadingUI, - initialFetchCompleted, - durationMs, - resultCount, - searchError, - lifecycleState, - } = state; - - const eventsPanelRef = useRef(null); - const headerRef = useRef(null); - const virtualListRef = useRef(null); - const searchInputRef = useRef(null); - - const { colWidths, onResizeStart, autoFitColumns } = useColumnResize(); - const { caseSensitive, directoryQuery, directoryScopeOpen } = searchParams; - const { eventColWidths, onEventResizeStart, autoFitEventColumns } = useEventColumnWidths(); - const { t, i18n } = useTranslation(); - // `resultsVersion` tracks raw backend search result-set changes. - // `displayedResultsVersion` additionally tracks UI ordering/projection changes (e.g. sort toggle). - const { - sortState, - displayedResults, - displayedResultsVersion, - sortThreshold, - setSortThreshold, - sortDisabledTooltip, - sortButtonsDisabled, - handleSortToggle, - } = useRemoteSort(results, resultsVersion, i18n.language, (limit) => - t('sorting.disabled', { limit }), - ); - - // Centralized selection management for the virtualized files list. - // Provides memoized helpers for click/keyboard selection and keeps Quick Look hooks fed. - const { - selectedIndices, - selectedIndicesRef, - activeRowIndex, - selectedPaths, - handleRowSelect, - selectSingleRow, - clearSelection, - moveSelection, - } = useSelection(displayedResults, displayedResultsVersion, virtualListRef); - - const navigateFromSearchToResults = useCallback(() => { - if (displayedResults.length === 0) { - return; - } - - selectSingleRow(0); - searchInputRef.current?.blur(); - }, [displayedResults.length, selectSingleRow]); - - const { - activeTab, - isSearchFocused, - handleSearchFocus, - handleSearchBlur, - eventFilterQuery, - setEventFilterQuery, - onTabChange, - searchInputValue, - directoryInputValue, - onQueryChange, - onDirectoryQueryChange, - onDirectoryInputKeyDown, - onSearchInputKeyDown, - submitFilesQuery, - } = useFilesTabState({ - searchQuery: searchParams.query, - directoryQuery, - queueSearch, - queueDirectorySearch, - onNavigateFromSearchToResults: navigateFromSearchToResults, - }); - const { filteredEvents } = useRecentFSEvents({ - caseSensitive, - isActive: activeTab === 'events', - eventFilterQuery, - }); - - const getQuickLookPaths = useCallback( - () => (activeTab === 'files' ? selectedPaths : []), - [activeTab, selectedPaths], - ); - // Quick Look controller keeps preview panel in sync with whichever rows are currently selected. - const { toggleQuickLook, updateQuickLook, closeQuickLook } = useQuickLook({ - getPaths: getQuickLookPaths, - }); - - const { - showContextMenu: showFilesContextMenu, - showHeaderContextMenu: showFilesHeaderContextMenu, - } = useContextMenu(autoFitColumns, toggleQuickLook); - - const { - showContextMenu: showEventsContextMenu, - showHeaderContextMenu: showEventsHeaderContextMenu, - } = useContextMenu(autoFitEventColumns); - - const { - status: fullDiskAccessStatus, - isChecking: isCheckingFullDiskAccess, - requestPermission: requestFullDiskAccessPermission, - } = useFullDiskAccessPermission(); - - const focusSearchInput = useCallback(() => { - requestAnimationFrame(() => { - const input = searchInputRef.current; - if (!input) return; - input.focus(); - }); - }, []); - - const focusAndSelectSearchInput = useCallback(() => { - requestAnimationFrame(() => { - const input = searchInputRef.current; - if (!input) return; - input.focus(); - input.select(); - }); - }, []); - - useEffect(() => { - focusAndSelectSearchInput(); - }, [focusAndSelectSearchInput]); - - const refreshSearchResults = useCallback(() => { - queueSearch(currentQuery, { immediate: true }); - }, [currentQuery, queueSearch]); - - const { - isPreferencesOpen, - closePreferences, - trayIconEnabled, - setTrayIconEnabled, - watchRoot, - defaultWatchRoot, - ignorePaths, - defaultIgnorePaths, - includePaths, - defaultIncludePaths, - preferencesResetToken, - handleWatchConfigChange, - handleResetPreferences, - } = useAppPreferences({ - fullDiskAccessStatus, - isCheckingFullDiskAccess, - refreshSearchResults, - i18n, - }); - - useAppWindowListeners({ - activeTab, - searchInputRef, - focusAndSelectSearchInput, - handleStatusUpdate, - setLifecycleState, - submitFilesQuery, - setEventFilterQuery, - }); - - const navigateSelection = useStableEvent(moveSelection); - const triggerQuickLook = useStableEvent(toggleQuickLook); - - useAppHotkeys({ - activeTab, - activeRowIndex, - selectedPaths, - selectedIndicesRef, - focusSearchInput, - focusAndSelectSearchInput, - clearSelection, - navigateSelection, - triggerQuickLook, - }); - - useFilesTabEffects({ - activeTab, - selectedIndices, - activeRowIndex, - closeQuickLook, - updateQuickLook, - clearSelection, - resultsVersion, - virtualListRef, - eventsPanelRef, - }); - - const onToggleCaseSensitive = useCallback( - (event: ChangeEvent) => { - const nextValue = event.target.checked; - updateSearchParams({ caseSensitive: nextValue }); - }, - [updateSearchParams], - ); - - const toggleDirectoryScope = useCallback(() => { - queueDirectoryScopeOpen(!directoryScopeOpen); - }, [directoryScopeOpen, queueDirectoryScopeOpen]); - - const handleHorizontalSync = useCallback((scrollLeft: number) => { - // VirtualList drives the scroll position; mirror it onto the sticky header for alignment. - if (headerRef.current) { - headerRef.current.scrollLeft = scrollLeft; - } - }, []); - - const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]); - - const handleRowContextMenu = useCallback( - (event: ReactMouseEvent, path: string, rowIndex: number) => { - const isRowSelected = selectedIndexSet.has(rowIndex); - if (!isRowSelected) { - selectSingleRow(rowIndex); - } - const targetPaths = isRowSelected && selectedPaths.length > 0 ? selectedPaths : [path]; - showFilesContextMenu(event, targetPaths); - }, - [selectedIndexSet, selectedPaths, selectSingleRow, showFilesContextMenu], - ); - - const handleEventsContextMenu = useCallback( - (event: ReactMouseEvent, path: string) => { - showEventsContextMenu(event, [path]); - }, - [showEventsContextMenu], - ); - - const renderRow = useCallback( - (rowIndex: number, item: SearchResultItem | undefined, rowStyle: CSSProperties) => { - if (!item) { - return ( -
- ); - } - - return ( - - ); - }, - [ - handleRowContextMenu, - handleRowSelect, - highlightTerms, - caseSensitive, - selectedIndexSet, - selectedPaths, - ], - ); - - const displayState: DisplayState = (() => { - if (!initialFetchCompleted) return 'loading'; - if (showLoadingUI) return 'loading'; - if (searchError) return 'error'; - if (results.length === 0) return 'empty'; - return 'results'; - })(); - const searchErrorMessage = - typeof searchError === 'string' ? searchError : (searchError?.message ?? null); - - const containerStyle = useMemo( - () => - ({ - '--w-filename': `${colWidths.filename}px`, - '--w-path': `${colWidths.path}px`, - '--w-size': `${colWidths.size}px`, - '--w-modified': `${colWidths.modified}px`, - '--w-created': `${colWidths.created}px`, - '--w-event-flags': `${eventColWidths.event}px`, - '--w-event-name': `${eventColWidths.name}px`, - '--w-event-path': `${eventColWidths.path}px`, - '--w-event-time': `${eventColWidths.time}px`, - '--columns-events-total': `${ - eventColWidths.event + eventColWidths.name + eventColWidths.path + eventColWidths.time - }px`, - }) as CSSProperties, - [colWidths, eventColWidths], - ); - - const showFullDiskAccessOverlay = fullDiskAccessStatus === 'denied'; - const overlayStatusMessage = isCheckingFullDiskAccess - ? t('app.fullDiskAccess.status.checking') - : t('app.fullDiskAccess.status.disabled'); - const caseSensitiveLabel = t('search.options.caseSensitive'); - const directoryScopeLabel = t('search.options.directoryScope'); - const searchPlaceholder = - activeTab === 'files' ? t('search.placeholder.files') : t('search.placeholder.events'); - const directorySearchPlaceholder = t('search.placeholder.directory'); - const searchAriaLabel = t('search.aria.searchInput'); - const permissionSteps = [ - t('app.fullDiskAccess.steps.one'), - t('app.fullDiskAccess.steps.two'), - t('app.fullDiskAccess.steps.three'), - ]; - const openSettingsLabel = t('app.fullDiskAccess.openSettings'); - const resultsContainerClassName = `results-container${ - isSearchFocused ? ' results-container--search-focused' : '' - }`; - - return ( - <> -
- -
- {activeTab === 'events' ? ( - - ) : ( - // `dataResultsVersion`: backend result-set changes. This resets row metadata cache. - // `displayedResultsVersion`: visible-order/projection changes. This refreshes viewport - // work such as icon hydration and frozen-view handoff in VirtualList. - - )} -
- -
- - {showFullDiskAccessOverlay && ( - - )} - - ); +function App(): React.JSX.Element { + return ; } export default App; diff --git a/cardinal/src/components/FileRow.tsx b/cardinal/src/components/FileRow.tsx index 695dba11..d86acd91 100644 --- a/cardinal/src/components/FileRow.tsx +++ b/cardinal/src/components/FileRow.tsx @@ -21,6 +21,10 @@ type FileRowProps = { selectedPathsForDrag?: string[]; caseInsensitive?: boolean; highlightTerms?: readonly string[]; + treeDepth?: number; + isExpandable?: boolean; + isExpanded?: boolean; + onToggleExpanded?: () => void; }; export const FileRow = memo(function FileRow({ @@ -34,6 +38,10 @@ export const FileRow = memo(function FileRow({ selectedPathsForDrag = [], caseInsensitive, highlightTerms, + treeDepth = 0, + isExpandable = false, + isExpanded = false, + onToggleExpanded, }: FileRowProps): React.JSX.Element { const pendingSelectRef = useRef<{ isShift: boolean; @@ -102,6 +110,12 @@ export const FileRow = memo(function FileRow({ } }; + const handleToggleExpanded = (e: ReactMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onToggleExpanded?.(); + }; + const handleDragStart = useCallback( (e: DragEvent) => { if (!path) { @@ -148,7 +162,26 @@ export const FileRow = memo(function FileRow({ aria-selected={isSelected} title={path} > -
+
+ {isExpandable ? ( + + ) : ( +