From 2555b00a8c9218e368550741ef98e2a83d6a9236 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sat, 13 Jun 2026 13:51:17 -0300 Subject: [PATCH 01/98] chore: remove gpui project files --- .github/workflows/aur-publish.yml | 105 - .github/workflows/release.yml | 98 - Cargo.lock | 10524 ---------------- Cargo.toml | 18 - README.md | 148 - THIRD_PARTY_NOTICES.md | 84 - aur/kosmos-bin/PKGBUILD | 46 - aur/kosmos/PKGBUILD | 52 - crates/ai/Cargo.toml | 6 - crates/ai/README.md | 15 - crates/ai/src/lib.rs | 14 - crates/app_shell/Cargo.toml | 24 - crates/app_shell/README.md | 17 - crates/app_shell/src/app.rs | 548 - crates/app_shell/src/delegates/header.rs | 27 - crates/app_shell/src/delegates/mod.rs | 222 - crates/app_shell/src/delegates/pane.rs | 309 - .../src/delegates/pane_tree_actions.rs | 35 - crates/app_shell/src/delegates/settings.rs | 102 - crates/app_shell/src/delegates/workspace.rs | 66 - crates/app_shell/src/lib.rs | 90 - crates/file_editor/Cargo.toml | 9 - crates/file_editor/README.md | 15 - crates/file_editor/src/lib.rs | 7 - crates/file_editor/src/parts/buffer.rs | 118 - crates/file_editor/src/parts/model.rs | 52 - crates/file_editor/src/parts/text.rs | 17 - crates/file_editor/src/store.rs | 165 - crates/file_search/Cargo.toml | 7 - crates/file_search/README.md | 15 - crates/file_search/src/lib.rs | 348 - crates/file_tree/Cargo.toml | 9 - crates/file_tree/README.md | 15 - crates/file_tree/src/lib.rs | 9 - crates/file_tree/src/ops.rs | 151 - crates/file_tree/src/state.rs | 6 - crates/file_tree/src/state/active.rs | 40 - crates/file_tree/src/state/entry_editing.rs | 118 - crates/file_tree/src/state/operations.rs | 131 - crates/file_tree/src/state/root_and_watch.rs | 217 - crates/file_tree/src/state/selection.rs | 122 - crates/file_tree/src/state/types.rs | 60 - crates/file_tree/src/watcher.rs | 76 - crates/git/Cargo.toml | 7 - crates/git/README.md | 14 - crates/git/src/lib.rs | 1626 --- crates/icons/Cargo.toml | 13 - crates/icons/README.md | 15 - crates/icons/src/assets.rs | 228 - crates/icons/src/language.rs | 62 - crates/icons/src/lib.rs | 456 - crates/icons/src/raster.rs | 101 - crates/installer/Cargo.toml | 10 - crates/installer/README.md | 14 - crates/installer/src/lib.rs | 354 - crates/kosmos/Cargo.toml | 7 - crates/kosmos/README.md | 16 - crates/kosmos/src/main.rs | 3 - crates/language/Cargo.toml | 6 - crates/language/README.md | 13 - crates/language/src/lib.rs | 465 - crates/lsp/Cargo.toml | 11 - crates/lsp/README.md | 16 - crates/lsp/src/lib.rs | 4 - crates/lsp/src/parts/client.rs | 346 - crates/lsp/src/parts/protocol_helpers.rs | 386 - crates/lsp/src/parts/tests.rs | 134 - crates/lsp/src/parts/types_and_sessions.rs | 313 - crates/pane_tree/Cargo.toml | 8 - crates/pane_tree/README.md | 16 - crates/pane_tree/src/lib.rs | 7 - crates/pane_tree/src/parts/helpers.rs | 200 - crates/pane_tree/src/parts/splits.rs | 55 - crates/pane_tree/src/parts/tabs.rs | 303 - crates/pane_tree/src/parts/types.rs | 43 - crates/pane_tree/src/tests/mod.rs | 224 - crates/panes/Cargo.toml | 7 - crates/panes/README.md | 15 - crates/panes/src/lib.rs | 90 - crates/persistence/Cargo.toml | 13 - crates/persistence/README.md | 14 - crates/persistence/src/db.rs | 26 - crates/persistence/src/lib.rs | 434 - .../src/migrations/001_initial.sql | 50 - .../src/migrations/002_tab_path.sql | 1 - .../003_file_tree_expanded_dirs.sql | 5 - crates/registry/Cargo.toml | 7 - crates/registry/README.md | 14 - crates/registry/src/entries.rs | 530 - crates/registry/src/lib.rs | 91 - crates/settings/Cargo.toml | 12 - crates/settings/README.md | 14 - crates/settings/src/lib.rs | 55 - .../settings/src/migrations/001_initial.sql | 5 - .../src/migrations/002_allow_list_type.sql | 12 - crates/settings/src/registry.rs | 150 - crates/settings/src/store.rs | 125 - crates/settings/src/value.rs | 39 - crates/shortcuts/Cargo.toml | 7 - crates/shortcuts/README.md | 13 - crates/shortcuts/src/lib.rs | 239 - crates/storage/Cargo.toml | 7 - crates/storage/README.md | 14 - crates/storage/src/lib.rs | 83 - crates/tabs/Cargo.toml | 6 - crates/tabs/README.md | 16 - crates/tabs/src/lib.rs | 48 - crates/tabs/src/registry.rs | 68 - crates/terminal/Cargo.toml | 9 - crates/terminal/README.md | 15 - crates/terminal/src/lib.rs | 3182 ----- crates/theme/Cargo.toml | 9 - crates/theme/README.md | 14 - crates/theme/src/lib.rs | 471 - crates/ui/Cargo.toml | 27 - crates/ui/README.md | 18 - crates/ui/src/delegate.rs | 332 - crates/ui/src/drag.rs | 137 - crates/ui/src/layout/header.rs | 3 - crates/ui/src/layout/header/main.rs | 138 - .../layout/header/menus_and_window_buttons.rs | 182 - crates/ui/src/layout/header/workspace_bar.rs | 240 - crates/ui/src/layout/landing.rs | 44 - crates/ui/src/layout/main_content/mod.rs | 38 - crates/ui/src/layout/main_content/pane.rs | 349 - .../ui/src/layout/main_content/pane_tree.rs | 167 - crates/ui/src/layout/main_content/tab.rs | 233 - crates/ui/src/layout/mod.rs | 3 - crates/ui/src/lib.rs | 10 - crates/ui/src/metrics.rs | 6 - crates/ui/src/pane_tree_actions.rs | 47 - crates/ui/src/tabs/blank/mod.rs | 64 - crates/ui/src/tabs/diff.rs | 2159 ---- crates/ui/src/tabs/file_editor/mod.rs | 17 - .../file_editor/parts/actions_and_elements.rs | 2355 ---- .../tabs/file_editor/parts/color_support.rs | 688 - .../ui/src/tabs/file_editor/parts/render.rs | 255 - crates/ui/src/tabs/file_editor/parts/tests.rs | 211 - crates/ui/src/tabs/file_search/mod.rs | 1988 --- crates/ui/src/tabs/file_tree/actions.rs | 54 - crates/ui/src/tabs/file_tree/drag.rs | 93 - crates/ui/src/tabs/file_tree/menu.rs | 242 - crates/ui/src/tabs/file_tree/mod.rs | 265 - crates/ui/src/tabs/file_tree/row.rs | 3 - .../src/tabs/file_tree/row/directory_rows.rs | 478 - .../tabs/file_tree/row/drag_and_helpers.rs | 33 - .../tabs/file_tree/row/file_and_entry_rows.rs | 55 - crates/ui/src/tabs/file_tree/state.rs | 79 - crates/ui/src/tabs/git/mod.rs | 20 - crates/ui/src/tabs/git/parts/async_actions.rs | 65 - .../git/parts/branch_and_remote_modals.rs | 341 - .../ui/src/tabs/git/parts/branches_modal.rs | 341 - crates/ui/src/tabs/git/parts/change_tree.rs | 487 - .../tabs/git/parts/modal_rows_and_buttons.rs | 131 - crates/ui/src/tabs/git/parts/panels.rs | 555 - crates/ui/src/tabs/git/parts/state_actions.rs | 896 -- .../ui/src/tabs/git/parts/state_and_header.rs | 373 - .../ui/src/tabs/git/parts/tags_and_stashes.rs | 396 - crates/ui/src/tabs/mod.rs | 144 - crates/ui/src/tabs/settings/cards.rs | 187 - crates/ui/src/tabs/settings/content.rs | 295 - crates/ui/src/tabs/settings/controls.rs | 525 - crates/ui/src/tabs/settings/mod.rs | 24 - crates/ui/src/tabs/settings/state.rs | 90 - crates/ui/src/tabs/terminal/mod.rs | 2260 ---- crates/ui/tests/tsx_highlighter.rs | 78 - crates/ui/tests/xml_highlighter.rs | 45 - crates/workspace/Cargo.toml | 7 - crates/workspace/README.md | 15 - crates/workspace/src/lib.rs | 203 - crates/zoom/Cargo.toml | 8 - crates/zoom/README.md | 14 - crates/zoom/src/lib.rs | 65 - packaging/linux/Kosmos.desktop.in | 14 - packaging/linux/deb-control.in | 10 - packaging/linux/kosmos.spec.in | 42 - scripts/bump-version.sh | 62 - scripts/bundle-linux.sh | 73 - scripts/install.sh | 61 - scripts/package-linux-appimage.sh | 207 - scripts/package-linux-deb-rpm.sh | 144 - scripts/tag-version.sh | 64 - scripts/uninstall.sh | 40 - vendor/gpui-component/Cargo.toml | 110 - vendor/gpui-component/LICENSE-APACHE | 191 - vendor/gpui-component/README.md | 201 - .../gpui-component/crates/assets/Cargo.toml | 28 - vendor/gpui-component/crates/assets/README.md | 7 - .../crates/assets/assets/.gitkeep | 0 .../assets/assets/icons/a-large-small.svg | 1 - .../crates/assets/assets/icons/arrow-down.svg | 1 - .../crates/assets/assets/icons/arrow-left.svg | 4 - .../assets/assets/icons/arrow-right.svg | 4 - .../crates/assets/assets/icons/arrow-up.svg | 1 - .../crates/assets/assets/icons/asterisk.svg | 1 - .../assets/assets/icons/battery-charging.svg | 16 - .../assets/assets/icons/battery-full.svg | 14 - .../assets/assets/icons/battery-low.svg | 18 - .../assets/assets/icons/battery-medium.svg | 18 - .../assets/assets/icons/battery-warning.svg | 16 - .../crates/assets/assets/icons/battery.svg | 18 - .../crates/assets/assets/icons/bell.svg | 1 - .../crates/assets/assets/icons/book-open.svg | 14 - .../crates/assets/assets/icons/bot.svg | 14 - .../crates/assets/assets/icons/building-2.svg | 16 - .../crates/assets/assets/icons/calendar.svg | 1 - .../assets/assets/icons/case-sensitive.svg | 1 - .../crates/assets/assets/icons/chart-pie.svg | 14 - .../crates/assets/assets/icons/check.svg | 3 - .../assets/assets/icons/chevron-down.svg | 3 - .../assets/assets/icons/chevron-left.svg | 3 - .../assets/assets/icons/chevron-right.svg | 3 - .../crates/assets/assets/icons/chevron-up.svg | 1 - .../assets/assets/icons/chevrons-up-down.svg | 4 - .../assets/assets/icons/circle-check.svg | 1 - .../assets/assets/icons/circle-user.svg | 14 - .../crates/assets/assets/icons/circle-x.svg | 1 - .../crates/assets/assets/icons/close.svg | 1 - .../crates/assets/assets/icons/copy.svg | 1 - .../crates/assets/assets/icons/cpu.svg | 24 - .../crates/assets/assets/icons/dash.svg | 3 - .../crates/assets/assets/icons/delete.svg | 1 - .../assets/assets/icons/ellipsis-vertical.svg | 1 - .../crates/assets/assets/icons/ellipsis.svg | 1 - .../assets/assets/icons/external-link.svg | 14 - .../crates/assets/assets/icons/eye-off.svg | 6 - .../crates/assets/assets/icons/eye.svg | 4 - .../crates/assets/assets/icons/file.svg | 14 - .../assets/assets/icons/folder-closed.svg | 14 - .../assets/assets/icons/folder-open.svg | 14 - .../crates/assets/assets/icons/folder.svg | 14 - .../crates/assets/assets/icons/frame.svg | 22 - .../assets/icons/gallery-vertical-end.svg | 18 - .../crates/assets/assets/icons/github.svg | 1 - .../crates/assets/assets/icons/globe.svg | 1 - .../crates/assets/assets/icons/hard-drive.svg | 19 - .../crates/assets/assets/icons/heart-off.svg | 1 - .../crates/assets/assets/icons/heart.svg | 1 - .../crates/assets/assets/icons/inbox.svg | 1 - .../crates/assets/assets/icons/info.svg | 1 - .../crates/assets/assets/icons/inspector.svg | 18 - .../assets/assets/icons/layout-dashboard.svg | 24 - .../assets/assets/icons/loader-circle.svg | 3 - .../crates/assets/assets/icons/loader.svg | 10 - .../crates/assets/assets/icons/map.svg | 14 - .../crates/assets/assets/icons/maximize.svg | 1 - .../assets/assets/icons/memory-stick.svg | 22 - .../crates/assets/assets/icons/menu.svg | 1 - .../crates/assets/assets/icons/minimize.svg | 6 - .../crates/assets/assets/icons/minus.svg | 3 - .../crates/assets/assets/icons/moon.svg | 1 - .../crates/assets/assets/icons/network.svg | 20 - .../crates/assets/assets/icons/palette.svg | 1 - .../assets/assets/icons/panel-bottom-open.svg | 1 - .../assets/assets/icons/panel-bottom.svg | 1 - .../assets/assets/icons/panel-left-close.svg | 14 - .../assets/assets/icons/panel-left-open.svg | 1 - .../crates/assets/assets/icons/panel-left.svg | 1 - .../assets/assets/icons/panel-right-close.svg | 14 - .../assets/assets/icons/panel-right-open.svg | 1 - .../assets/assets/icons/panel-right.svg | 1 - .../crates/assets/assets/icons/pause.svg | 1 - .../crates/assets/assets/icons/play.svg | 1 - .../crates/assets/assets/icons/plus.svg | 1 - .../crates/assets/assets/icons/redo-2.svg | 14 - .../crates/assets/assets/icons/redo.svg | 14 - .../crates/assets/assets/icons/replace.svg | 16 - .../assets/assets/icons/resize-corner.svg | 9 - .../crates/assets/assets/icons/search.svg | 4 - .../crates/assets/assets/icons/settings-2.svg | 16 - .../crates/assets/assets/icons/settings.svg | 14 - .../assets/assets/icons/sort-ascending.svg | 15 - .../assets/assets/icons/sort-descending.svg | 15 - .../assets/assets/icons/square-terminal.svg | 19 - .../crates/assets/assets/icons/star-fill.svg | 1 - .../crates/assets/assets/icons/star-off.svg | 1 - .../crates/assets/assets/icons/star.svg | 1 - .../crates/assets/assets/icons/sun.svg | 1 - .../assets/assets/icons/thumbs-down.svg | 1 - .../crates/assets/assets/icons/thumbs-up.svg | 1 - .../assets/assets/icons/triangle-alert.svg | 1 - .../crates/assets/assets/icons/undo-2.svg | 14 - .../crates/assets/assets/icons/undo.svg | 14 - .../crates/assets/assets/icons/user.svg | 16 - .../assets/assets/icons/window-close.svg | 9 - .../assets/assets/icons/window-maximize.svg | 9 - .../assets/assets/icons/window-minimize.svg | 9 - .../assets/assets/icons/window-restore.svg | 10 - .../gpui-component/crates/assets/src/lib.rs | 31 - .../crates/assets/src/native_assets.rs | 34 - .../crates/assets/src/wasm_assets.rs | 108 - .../gpui-component/crates/macros/Cargo.toml | 21 - .../crates/macros/LICENSE-APACHE | 1 - .../crates/macros/src/derive_into_plot.rs | 73 - .../gpui-component/crates/macros/src/lib.rs | 209 - vendor/gpui-component/crates/ui/Cargo.toml | 196 - .../gpui-component/crates/ui/LICENSE-APACHE | 191 - vendor/gpui-component/crates/ui/build.rs | 19 - .../gpui-component/crates/ui/locales/ui.yml | 257 - .../gpui-component/crates/ui/src/accordion.rs | 311 - .../gpui-component/crates/ui/src/actions.rs | 26 - vendor/gpui-component/crates/ui/src/alert.rs | 254 - .../gpui-component/crates/ui/src/animation.rs | 194 - .../crates/ui/src/async_util.rs | 9 - .../crates/ui/src/avatar/avatar.rs | 168 - .../crates/ui/src/avatar/avatar_group.rs | 131 - .../crates/ui/src/avatar/mod.rs | 39 - vendor/gpui-component/crates/ui/src/badge.rs | 165 - .../crates/ui/src/breadcrumb.rs | 173 - .../crates/ui/src/button/button.rs | 1027 -- .../crates/ui/src/button/button_group.rs | 294 - .../crates/ui/src/button/button_icon.rs | 168 - .../crates/ui/src/button/dropdown_button.rs | 249 - .../crates/ui/src/button/mod.rs | 11 - .../crates/ui/src/button/toggle.rs | 425 - .../crates/ui/src/chart/area_chart.rs | 195 - .../crates/ui/src/chart/bar_chart.rs | 474 - .../crates/ui/src/chart/candlestick_chart.rs | 238 - .../crates/ui/src/chart/line_chart.rs | 180 - .../gpui-component/crates/ui/src/chart/mod.rs | 85 - .../crates/ui/src/chart/pie_chart.rs | 148 - .../gpui-component/crates/ui/src/checkbox.rs | 320 - .../gpui-component/crates/ui/src/clipboard.rs | 125 - .../crates/ui/src/collapsible.rs | 80 - .../crates/ui/src/color_picker.rs | 851 -- .../gpui-component/crates/ui/src/combobox.rs | 1342 -- .../crates/ui/src/description_list.rs | 384 - .../crates/ui/src/dialog/alert_dialog.rs | 367 - .../crates/ui/src/dialog/content.rs | 44 - .../crates/ui/src/dialog/description.rs | 53 - .../crates/ui/src/dialog/dialog.rs | 670 - .../crates/ui/src/dialog/footer.rs | 126 - .../crates/ui/src/dialog/header.rs | 50 - .../crates/ui/src/dialog/mod.rs | 15 - .../crates/ui/src/dialog/title.rs | 46 - .../gpui-component/crates/ui/src/dock/dock.rs | 499 - .../crates/ui/src/dock/invalid_panel.rs | 59 - .../gpui-component/crates/ui/src/dock/mod.rs | 1175 -- .../crates/ui/src/dock/panel.rs | 371 - .../crates/ui/src/dock/stack_panel.rs | 434 - .../crates/ui/src/dock/state.rs | 282 - .../crates/ui/src/dock/tab_panel.rs | 1214 -- .../crates/ui/src/dock/tiles.rs | 1231 -- .../crates/ui/src/element_ext.rs | 60 - vendor/gpui-component/crates/ui/src/event.rs | 21 - .../crates/ui/src/fixtures/layout.json | 261 - .../crates/ui/src/focus_trap.rs | 222 - .../crates/ui/src/form/field.rs | 350 - .../gpui-component/crates/ui/src/form/form.rs | 117 - .../gpui-component/crates/ui/src/form/mod.rs | 20 - .../gpui-component/crates/ui/src/geometry.rs | 151 - .../crates/ui/src/global_state.rs | 69 - .../gpui-component/crates/ui/src/group_box.rs | 194 - .../crates/ui/src/highlighter/diagnostics.rs | 395 - .../crates/ui/src/highlighter/highlighter.rs | 1313 -- .../crates/ui/src/highlighter/languages.rs | 722 -- .../highlighter/languages/go/highlights.scm | 136 - .../highlighter/languages/html/highlights.scm | 13 - .../highlighter/languages/html/injections.scm | 7 - .../languages/javascript/highlights.scm | 207 - .../languages/javascript/injections.scm | 54 - .../highlighter/languages/json/highlights.scm | 24 - .../languages/kotlin/highlights.scm | 345 - .../highlighter/languages/lua/highlights.scm | 152 - .../languages/markdown/highlights.scm | 52 - .../languages/markdown/injections.scm | 14 - .../languages/markdown_inline/highlights.scm | 25 - .../highlighter/languages/php/injections.scm | 20 - .../src/highlighter/languages/rust/README.md | 1 - .../highlighter/languages/rust/highlights.scm | 165 - .../highlighter/languages/rust/injections.scm | 9 - .../languages/typescript/highlights-tsx.scm | 30 - .../languages/typescript/highlights.scm | 243 - .../highlighter/languages/zig/highlights.scm | 291 - .../highlighter/languages/zig/injections.scm | 2 - .../crates/ui/src/highlighter/mod.rs | 24 - .../crates/ui/src/highlighter/registry.rs | 550 - .../crates/ui/src/highlighter/wasm_stub.rs | 304 - .../gpui-component/crates/ui/src/history.rs | 303 - .../crates/ui/src/hover_card.rs | 325 - vendor/gpui-component/crates/ui/src/icon.rs | 190 - .../crates/ui/src/index_path.rs | 102 - .../crates/ui/src/input/blink_cursor.rs | 95 - .../crates/ui/src/input/change.rs | 39 - .../crates/ui/src/input/clear_button.rs | 16 - .../crates/ui/src/input/cursor.rs | 69 - .../crates/ui/src/input/display_map/README.md | 46 - .../ui/src/input/display_map/display_map.rs | 333 - .../ui/src/input/display_map/fold_map.rs | 343 - .../ui/src/input/display_map/folding.rs | 181 - .../crates/ui/src/input/display_map/mod.rs | 69 - .../ui/src/input/display_map/text_wrapper.rs | 1061 -- .../ui/src/input/display_map/wrap_map.rs | 222 - .../crates/ui/src/input/element.rs | 2555 ---- .../crates/ui/src/input/indent.rs | 432 - .../crates/ui/src/input/input.rs | 431 - .../crates/ui/src/input/lsp/code_actions.rs | 140 - .../crates/ui/src/input/lsp/completions.rs | 310 - .../crates/ui/src/input/lsp/definitions.rs | 233 - .../ui/src/input/lsp/document_colors.rs | 110 - .../crates/ui/src/input/lsp/hover.rs | 130 - .../crates/ui/src/input/lsp/mod.rs | 159 - .../crates/ui/src/input/mask_pattern.rs | 637 - .../gpui-component/crates/ui/src/input/mod.rs | 38 - .../crates/ui/src/input/mode.rs | 466 - .../crates/ui/src/input/movement.rs | 274 - .../crates/ui/src/input/number_input.rs | 224 - .../crates/ui/src/input/otp_input.rs | 379 - .../ui/src/input/popovers/code_action_menu.rs | 337 - .../ui/src/input/popovers/completion_menu.rs | 457 - .../ui/src/input/popovers/context_menu.rs | 160 - .../ui/src/input/popovers/hover_popover.rs | 410 - .../crates/ui/src/input/popovers/mod.rs | 79 - .../crates/ui/src/input/rope_ext.rs | 724 -- .../crates/ui/src/input/search.rs | 641 - .../crates/ui/src/input/selection.rs | 247 - .../crates/ui/src/input/state.rs | 2875 ----- .../gpui-component/crates/ui/src/inspector.rs | 686 - vendor/gpui-component/crates/ui/src/kbd.rs | 324 - vendor/gpui-component/crates/ui/src/label.rs | 396 - vendor/gpui-component/crates/ui/src/lib.rs | 185 - vendor/gpui-component/crates/ui/src/link.rs | 104 - .../crates/ui/src/list/cache.rs | 384 - .../crates/ui/src/list/delegate.rs | 171 - .../gpui-component/crates/ui/src/list/list.rs | 761 -- .../crates/ui/src/list/list_item.rs | 254 - .../crates/ui/src/list/loading.rs | 32 - .../gpui-component/crates/ui/src/list/mod.rs | 28 - .../crates/ui/src/list/separator_item.rs | 43 - .../crates/ui/src/menu/app_menu_bar.rs | 349 - .../crates/ui/src/menu/context_menu.rs | 325 - .../crates/ui/src/menu/dropdown_menu.rs | 138 - .../crates/ui/src/menu/menu_item.rs | 123 - .../gpui-component/crates/ui/src/menu/mod.rs | 17 - .../crates/ui/src/menu/popup_menu.rs | 1356 -- .../crates/ui/src/notification.rs | 766 -- .../crates/ui/src/pagination.rs | 346 - .../gpui-component/crates/ui/src/plot/axis.rs | 207 - .../gpui-component/crates/ui/src/plot/grid.rs | 95 - .../crates/ui/src/plot/label.rs | 139 - .../gpui-component/crates/ui/src/plot/mod.rs | 53 - .../crates/ui/src/plot/scale.rs | 26 - .../crates/ui/src/plot/scale/band.rs | 130 - .../crates/ui/src/plot/scale/linear.rs | 150 - .../crates/ui/src/plot/scale/ordinal.rs | 104 - .../crates/ui/src/plot/scale/point.rs | 195 - .../crates/ui/src/plot/scale/sealed.rs | 6 - .../crates/ui/src/plot/shape.rs | 13 - .../crates/ui/src/plot/shape/arc.rs | 231 - .../crates/ui/src/plot/shape/area.rs | 191 - .../crates/ui/src/plot/shape/bar.rs | 291 - .../crates/ui/src/plot/shape/line.rs | 238 - .../crates/ui/src/plot/shape/pie.rs | 134 - .../crates/ui/src/plot/shape/stack.rs | 191 - .../crates/ui/src/plot/tooltip.rs | 291 - .../gpui-component/crates/ui/src/popover.rs | 531 - .../crates/ui/src/progress/mod.rs | 38 - .../crates/ui/src/progress/progress.rs | 161 - .../crates/ui/src/progress/progress_circle.rs | 222 - vendor/gpui-component/crates/ui/src/radio.rs | 373 - vendor/gpui-component/crates/ui/src/rating.rs | 203 - .../crates/ui/src/resizable/mod.rs | 334 - .../crates/ui/src/resizable/panel.rs | 456 - .../crates/ui/src/resizable/resize_handle.rs | 220 - vendor/gpui-component/crates/ui/src/root.rs | 511 - .../crates/ui/src/scroll/mod.rs | 7 - .../crates/ui/src/scroll/scrollable.rs | 207 - .../crates/ui/src/scroll/scrollable_mask.rs | 162 - .../crates/ui/src/scroll/scrollbar.rs | 977 -- .../crates/ui/src/searchable_list.rs | 13 - .../crates/ui/src/searchable_list/adapter.rs | 180 - .../crates/ui/src/searchable_list/change.rs | 13 - .../crates/ui/src/searchable_list/delegate.rs | 200 - .../crates/ui/src/searchable_list/item.rs | 138 - .../crates/ui/src/searchable_list/state.rs | 216 - .../crates/ui/src/searchable_list/vec.rs | 239 - vendor/gpui-component/crates/ui/src/select.rs | 810 -- .../gpui-component/crates/ui/src/separator.rs | 155 - .../crates/ui/src/setting/fields/bool.rs | 59 - .../crates/ui/src/setting/fields/dropdown.rs | 89 - .../crates/ui/src/setting/fields/element.rs | 92 - .../crates/ui/src/setting/fields/mod.rs | 337 - .../crates/ui/src/setting/fields/number.rs | 152 - .../crates/ui/src/setting/fields/string.rs | 89 - .../crates/ui/src/setting/group.rs | 122 - .../crates/ui/src/setting/item.rs | 252 - .../crates/ui/src/setting/mod.rs | 11 - .../crates/ui/src/setting/page.rs | 210 - .../crates/ui/src/setting/settings.rs | 301 - vendor/gpui-component/crates/ui/src/sheet.rs | 291 - .../crates/ui/src/sidebar/footer.rs | 88 - .../crates/ui/src/sidebar/group.rs | 86 - .../crates/ui/src/sidebar/header.rs | 100 - .../crates/ui/src/sidebar/menu.rs | 385 - .../crates/ui/src/sidebar/mod.rs | 769 -- .../gpui-component/crates/ui/src/skeleton.rs | 59 - vendor/gpui-component/crates/ui/src/slider.rs | 699 - .../gpui-component/crates/ui/src/spinner.rs | 67 - .../crates/ui/src/stepper/item.rs | 262 - .../crates/ui/src/stepper/mod.rs | 6 - .../crates/ui/src/stepper/stepper.rs | 135 - .../crates/ui/src/stepper/trigger.rs | 151 - vendor/gpui-component/crates/ui/src/styled.rs | 702 -- vendor/gpui-component/crates/ui/src/switch.rs | 222 - .../gpui-component/crates/ui/src/tab/mod.rs | 5 - .../gpui-component/crates/ui/src/tab/tab.rs | 694 - .../crates/ui/src/tab/tab_bar.rs | 518 - .../crates/ui/src/table/column.rs | 299 - .../crates/ui/src/table/data_table.rs | 173 - .../crates/ui/src/table/delegate.rs | 229 - .../crates/ui/src/table/loading.rs | 109 - .../gpui-component/crates/ui/src/table/mod.rs | 18 - .../crates/ui/src/table/state.rs | 2331 ---- .../crates/ui/src/table/table.rs | 638 - vendor/gpui-component/crates/ui/src/tag.rs | 269 - .../crates/ui/src/text/document.rs | 113 - .../crates/ui/src/text/format/html.rs | 758 -- .../ui/src/text/format/html5minify/mod.rs | 899 -- .../crates/ui/src/text/format/markdown.rs | 538 - .../crates/ui/src/text/format/mod.rs | 3 - .../crates/ui/src/text/inline.rs | 670 - .../gpui-component/crates/ui/src/text/mod.rs | 96 - .../gpui-component/crates/ui/src/text/node.rs | 1288 -- .../crates/ui/src/text/state.rs | 562 - .../crates/ui/src/text/style.rs | 67 - .../crates/ui/src/text/text_view.rs | 409 - .../crates/ui/src/text/utils.rs | 61 - .../crates/ui/src/theme/color.rs | 918 -- .../crates/ui/src/theme/default-colors.json | 2244 ---- .../crates/ui/src/theme/default-theme.json | 421 - .../gpui-component/crates/ui/src/theme/mod.rs | 283 - .../crates/ui/src/theme/registry.rs | 287 - .../crates/ui/src/theme/schema.rs | 715 -- .../crates/ui/src/theme/theme_color.rs | 257 - .../crates/ui/src/time/calendar.rs | 1014 -- .../crates/ui/src/time/date_picker.rs | 516 - .../gpui-component/crates/ui/src/time/mod.rs | 3 - .../crates/ui/src/time/utils.rs | 181 - .../gpui-component/crates/ui/src/title_bar.rs | 335 - .../gpui-component/crates/ui/src/tooltip.rs | 734 -- vendor/gpui-component/crates/ui/src/tree.rs | 677 - .../crates/ui/src/virtual_list.rs | 766 -- .../crates/ui/src/window_border.rs | 238 - .../crates/ui/src/window_ext.rs | 204 - 544 files changed, 126605 deletions(-) delete mode 100644 .github/workflows/aur-publish.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 THIRD_PARTY_NOTICES.md delete mode 100644 aur/kosmos-bin/PKGBUILD delete mode 100644 aur/kosmos/PKGBUILD delete mode 100644 crates/ai/Cargo.toml delete mode 100644 crates/ai/README.md delete mode 100644 crates/ai/src/lib.rs delete mode 100644 crates/app_shell/Cargo.toml delete mode 100644 crates/app_shell/README.md delete mode 100644 crates/app_shell/src/app.rs delete mode 100644 crates/app_shell/src/delegates/header.rs delete mode 100644 crates/app_shell/src/delegates/mod.rs delete mode 100644 crates/app_shell/src/delegates/pane.rs delete mode 100644 crates/app_shell/src/delegates/pane_tree_actions.rs delete mode 100644 crates/app_shell/src/delegates/settings.rs delete mode 100644 crates/app_shell/src/delegates/workspace.rs delete mode 100644 crates/app_shell/src/lib.rs delete mode 100644 crates/file_editor/Cargo.toml delete mode 100644 crates/file_editor/README.md delete mode 100644 crates/file_editor/src/lib.rs delete mode 100644 crates/file_editor/src/parts/buffer.rs delete mode 100644 crates/file_editor/src/parts/model.rs delete mode 100644 crates/file_editor/src/parts/text.rs delete mode 100644 crates/file_editor/src/store.rs delete mode 100644 crates/file_search/Cargo.toml delete mode 100644 crates/file_search/README.md delete mode 100644 crates/file_search/src/lib.rs delete mode 100644 crates/file_tree/Cargo.toml delete mode 100644 crates/file_tree/README.md delete mode 100644 crates/file_tree/src/lib.rs delete mode 100644 crates/file_tree/src/ops.rs delete mode 100644 crates/file_tree/src/state.rs delete mode 100644 crates/file_tree/src/state/active.rs delete mode 100644 crates/file_tree/src/state/entry_editing.rs delete mode 100644 crates/file_tree/src/state/operations.rs delete mode 100644 crates/file_tree/src/state/root_and_watch.rs delete mode 100644 crates/file_tree/src/state/selection.rs delete mode 100644 crates/file_tree/src/state/types.rs delete mode 100644 crates/file_tree/src/watcher.rs delete mode 100644 crates/git/Cargo.toml delete mode 100644 crates/git/README.md delete mode 100644 crates/git/src/lib.rs delete mode 100644 crates/icons/Cargo.toml delete mode 100644 crates/icons/README.md delete mode 100644 crates/icons/src/assets.rs delete mode 100644 crates/icons/src/language.rs delete mode 100644 crates/icons/src/lib.rs delete mode 100644 crates/icons/src/raster.rs delete mode 100644 crates/installer/Cargo.toml delete mode 100644 crates/installer/README.md delete mode 100644 crates/installer/src/lib.rs delete mode 100644 crates/kosmos/Cargo.toml delete mode 100644 crates/kosmos/README.md delete mode 100644 crates/kosmos/src/main.rs delete mode 100644 crates/language/Cargo.toml delete mode 100644 crates/language/README.md delete mode 100644 crates/language/src/lib.rs delete mode 100644 crates/lsp/Cargo.toml delete mode 100644 crates/lsp/README.md delete mode 100644 crates/lsp/src/lib.rs delete mode 100644 crates/lsp/src/parts/client.rs delete mode 100644 crates/lsp/src/parts/protocol_helpers.rs delete mode 100644 crates/lsp/src/parts/tests.rs delete mode 100644 crates/lsp/src/parts/types_and_sessions.rs delete mode 100644 crates/pane_tree/Cargo.toml delete mode 100644 crates/pane_tree/README.md delete mode 100644 crates/pane_tree/src/lib.rs delete mode 100644 crates/pane_tree/src/parts/helpers.rs delete mode 100644 crates/pane_tree/src/parts/splits.rs delete mode 100644 crates/pane_tree/src/parts/tabs.rs delete mode 100644 crates/pane_tree/src/parts/types.rs delete mode 100644 crates/pane_tree/src/tests/mod.rs delete mode 100644 crates/panes/Cargo.toml delete mode 100644 crates/panes/README.md delete mode 100644 crates/panes/src/lib.rs delete mode 100644 crates/persistence/Cargo.toml delete mode 100644 crates/persistence/README.md delete mode 100644 crates/persistence/src/db.rs delete mode 100644 crates/persistence/src/lib.rs delete mode 100644 crates/persistence/src/migrations/001_initial.sql delete mode 100644 crates/persistence/src/migrations/002_tab_path.sql delete mode 100644 crates/persistence/src/migrations/003_file_tree_expanded_dirs.sql delete mode 100644 crates/registry/Cargo.toml delete mode 100644 crates/registry/README.md delete mode 100644 crates/registry/src/entries.rs delete mode 100644 crates/registry/src/lib.rs delete mode 100644 crates/settings/Cargo.toml delete mode 100644 crates/settings/README.md delete mode 100644 crates/settings/src/lib.rs delete mode 100644 crates/settings/src/migrations/001_initial.sql delete mode 100644 crates/settings/src/migrations/002_allow_list_type.sql delete mode 100644 crates/settings/src/registry.rs delete mode 100644 crates/settings/src/store.rs delete mode 100644 crates/settings/src/value.rs delete mode 100644 crates/shortcuts/Cargo.toml delete mode 100644 crates/shortcuts/README.md delete mode 100644 crates/shortcuts/src/lib.rs delete mode 100644 crates/storage/Cargo.toml delete mode 100644 crates/storage/README.md delete mode 100644 crates/storage/src/lib.rs delete mode 100644 crates/tabs/Cargo.toml delete mode 100644 crates/tabs/README.md delete mode 100644 crates/tabs/src/lib.rs delete mode 100644 crates/tabs/src/registry.rs delete mode 100644 crates/terminal/Cargo.toml delete mode 100644 crates/terminal/README.md delete mode 100644 crates/terminal/src/lib.rs delete mode 100644 crates/theme/Cargo.toml delete mode 100644 crates/theme/README.md delete mode 100644 crates/theme/src/lib.rs delete mode 100644 crates/ui/Cargo.toml delete mode 100644 crates/ui/README.md delete mode 100644 crates/ui/src/delegate.rs delete mode 100644 crates/ui/src/drag.rs delete mode 100644 crates/ui/src/layout/header.rs delete mode 100644 crates/ui/src/layout/header/main.rs delete mode 100644 crates/ui/src/layout/header/menus_and_window_buttons.rs delete mode 100644 crates/ui/src/layout/header/workspace_bar.rs delete mode 100644 crates/ui/src/layout/landing.rs delete mode 100644 crates/ui/src/layout/main_content/mod.rs delete mode 100644 crates/ui/src/layout/main_content/pane.rs delete mode 100644 crates/ui/src/layout/main_content/pane_tree.rs delete mode 100644 crates/ui/src/layout/main_content/tab.rs delete mode 100644 crates/ui/src/layout/mod.rs delete mode 100644 crates/ui/src/lib.rs delete mode 100644 crates/ui/src/metrics.rs delete mode 100644 crates/ui/src/pane_tree_actions.rs delete mode 100644 crates/ui/src/tabs/blank/mod.rs delete mode 100644 crates/ui/src/tabs/diff.rs delete mode 100644 crates/ui/src/tabs/file_editor/mod.rs delete mode 100644 crates/ui/src/tabs/file_editor/parts/actions_and_elements.rs delete mode 100644 crates/ui/src/tabs/file_editor/parts/color_support.rs delete mode 100644 crates/ui/src/tabs/file_editor/parts/render.rs delete mode 100644 crates/ui/src/tabs/file_editor/parts/tests.rs delete mode 100644 crates/ui/src/tabs/file_search/mod.rs delete mode 100644 crates/ui/src/tabs/file_tree/actions.rs delete mode 100644 crates/ui/src/tabs/file_tree/drag.rs delete mode 100644 crates/ui/src/tabs/file_tree/menu.rs delete mode 100644 crates/ui/src/tabs/file_tree/mod.rs delete mode 100644 crates/ui/src/tabs/file_tree/row.rs delete mode 100644 crates/ui/src/tabs/file_tree/row/directory_rows.rs delete mode 100644 crates/ui/src/tabs/file_tree/row/drag_and_helpers.rs delete mode 100644 crates/ui/src/tabs/file_tree/row/file_and_entry_rows.rs delete mode 100644 crates/ui/src/tabs/file_tree/state.rs delete mode 100644 crates/ui/src/tabs/git/mod.rs delete mode 100644 crates/ui/src/tabs/git/parts/async_actions.rs delete mode 100644 crates/ui/src/tabs/git/parts/branch_and_remote_modals.rs delete mode 100644 crates/ui/src/tabs/git/parts/branches_modal.rs delete mode 100644 crates/ui/src/tabs/git/parts/change_tree.rs delete mode 100644 crates/ui/src/tabs/git/parts/modal_rows_and_buttons.rs delete mode 100644 crates/ui/src/tabs/git/parts/panels.rs delete mode 100644 crates/ui/src/tabs/git/parts/state_actions.rs delete mode 100644 crates/ui/src/tabs/git/parts/state_and_header.rs delete mode 100644 crates/ui/src/tabs/git/parts/tags_and_stashes.rs delete mode 100644 crates/ui/src/tabs/mod.rs delete mode 100644 crates/ui/src/tabs/settings/cards.rs delete mode 100644 crates/ui/src/tabs/settings/content.rs delete mode 100644 crates/ui/src/tabs/settings/controls.rs delete mode 100644 crates/ui/src/tabs/settings/mod.rs delete mode 100644 crates/ui/src/tabs/settings/state.rs delete mode 100644 crates/ui/src/tabs/terminal/mod.rs delete mode 100644 crates/ui/tests/tsx_highlighter.rs delete mode 100644 crates/ui/tests/xml_highlighter.rs delete mode 100644 crates/workspace/Cargo.toml delete mode 100644 crates/workspace/README.md delete mode 100644 crates/workspace/src/lib.rs delete mode 100644 crates/zoom/Cargo.toml delete mode 100644 crates/zoom/README.md delete mode 100644 crates/zoom/src/lib.rs delete mode 100644 packaging/linux/Kosmos.desktop.in delete mode 100644 packaging/linux/deb-control.in delete mode 100644 packaging/linux/kosmos.spec.in delete mode 100755 scripts/bump-version.sh delete mode 100755 scripts/bundle-linux.sh delete mode 100755 scripts/install.sh delete mode 100755 scripts/package-linux-appimage.sh delete mode 100755 scripts/package-linux-deb-rpm.sh delete mode 100755 scripts/tag-version.sh delete mode 100755 scripts/uninstall.sh delete mode 100644 vendor/gpui-component/Cargo.toml delete mode 100644 vendor/gpui-component/LICENSE-APACHE delete mode 100644 vendor/gpui-component/README.md delete mode 100644 vendor/gpui-component/crates/assets/Cargo.toml delete mode 100644 vendor/gpui-component/crates/assets/README.md delete mode 100644 vendor/gpui-component/crates/assets/assets/.gitkeep delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/a-large-small.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/arrow-down.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/arrow-left.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/arrow-right.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/arrow-up.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/asterisk.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery-charging.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery-full.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery-low.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery-medium.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery-warning.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/battery.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/bell.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/book-open.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/bot.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/building-2.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/calendar.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/case-sensitive.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chart-pie.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/check.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chevron-down.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chevron-left.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chevron-right.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chevron-up.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/chevrons-up-down.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/circle-check.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/circle-user.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/circle-x.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/close.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/copy.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/cpu.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/dash.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/delete.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/ellipsis-vertical.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/ellipsis.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/external-link.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/eye-off.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/eye.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/file.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/folder-closed.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/folder-open.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/folder.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/frame.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/gallery-vertical-end.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/github.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/globe.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/hard-drive.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/heart-off.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/heart.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/inbox.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/info.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/inspector.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/layout-dashboard.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/loader-circle.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/loader.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/map.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/maximize.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/memory-stick.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/menu.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/minimize.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/minus.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/moon.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/network.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/palette.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-bottom-open.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-bottom.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-left-close.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-left-open.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-left.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-right-close.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-right-open.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/panel-right.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/pause.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/play.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/plus.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/redo-2.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/redo.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/replace.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/resize-corner.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/search.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/settings-2.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/settings.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/sort-ascending.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/sort-descending.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/square-terminal.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/star-fill.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/star-off.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/star.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/sun.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/thumbs-down.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/thumbs-up.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/triangle-alert.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/undo-2.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/undo.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/user.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/window-close.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/window-maximize.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/window-minimize.svg delete mode 100644 vendor/gpui-component/crates/assets/assets/icons/window-restore.svg delete mode 100644 vendor/gpui-component/crates/assets/src/lib.rs delete mode 100644 vendor/gpui-component/crates/assets/src/native_assets.rs delete mode 100644 vendor/gpui-component/crates/assets/src/wasm_assets.rs delete mode 100644 vendor/gpui-component/crates/macros/Cargo.toml delete mode 120000 vendor/gpui-component/crates/macros/LICENSE-APACHE delete mode 100644 vendor/gpui-component/crates/macros/src/derive_into_plot.rs delete mode 100644 vendor/gpui-component/crates/macros/src/lib.rs delete mode 100644 vendor/gpui-component/crates/ui/Cargo.toml delete mode 100644 vendor/gpui-component/crates/ui/LICENSE-APACHE delete mode 100644 vendor/gpui-component/crates/ui/build.rs delete mode 100644 vendor/gpui-component/crates/ui/locales/ui.yml delete mode 100644 vendor/gpui-component/crates/ui/src/accordion.rs delete mode 100644 vendor/gpui-component/crates/ui/src/actions.rs delete mode 100644 vendor/gpui-component/crates/ui/src/alert.rs delete mode 100644 vendor/gpui-component/crates/ui/src/animation.rs delete mode 100644 vendor/gpui-component/crates/ui/src/async_util.rs delete mode 100644 vendor/gpui-component/crates/ui/src/avatar/avatar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/avatar/avatar_group.rs delete mode 100644 vendor/gpui-component/crates/ui/src/avatar/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/badge.rs delete mode 100644 vendor/gpui-component/crates/ui/src/breadcrumb.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/button.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/button_group.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/button_icon.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/dropdown_button.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/button/toggle.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/area_chart.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/bar_chart.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/candlestick_chart.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/line_chart.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/chart/pie_chart.rs delete mode 100644 vendor/gpui-component/crates/ui/src/checkbox.rs delete mode 100644 vendor/gpui-component/crates/ui/src/clipboard.rs delete mode 100644 vendor/gpui-component/crates/ui/src/collapsible.rs delete mode 100644 vendor/gpui-component/crates/ui/src/color_picker.rs delete mode 100644 vendor/gpui-component/crates/ui/src/combobox.rs delete mode 100644 vendor/gpui-component/crates/ui/src/description_list.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/alert_dialog.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/content.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/description.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/dialog.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/footer.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/header.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dialog/title.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/dock.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/invalid_panel.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/panel.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/stack_panel.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/tab_panel.rs delete mode 100644 vendor/gpui-component/crates/ui/src/dock/tiles.rs delete mode 100644 vendor/gpui-component/crates/ui/src/element_ext.rs delete mode 100644 vendor/gpui-component/crates/ui/src/event.rs delete mode 100644 vendor/gpui-component/crates/ui/src/fixtures/layout.json delete mode 100644 vendor/gpui-component/crates/ui/src/focus_trap.rs delete mode 100644 vendor/gpui-component/crates/ui/src/form/field.rs delete mode 100644 vendor/gpui-component/crates/ui/src/form/form.rs delete mode 100644 vendor/gpui-component/crates/ui/src/form/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/geometry.rs delete mode 100644 vendor/gpui-component/crates/ui/src/global_state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/group_box.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/diagnostics.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/highlighter.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/go/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/html/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/html/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/javascript/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/javascript/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/json/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/kotlin/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/lua/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/markdown/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/markdown/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/markdown_inline/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/php/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/rust/README.md delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/rust/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/rust/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/typescript/highlights-tsx.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/typescript/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/zig/highlights.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/languages/zig/injections.scm delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/registry.rs delete mode 100644 vendor/gpui-component/crates/ui/src/highlighter/wasm_stub.rs delete mode 100644 vendor/gpui-component/crates/ui/src/history.rs delete mode 100644 vendor/gpui-component/crates/ui/src/hover_card.rs delete mode 100644 vendor/gpui-component/crates/ui/src/icon.rs delete mode 100644 vendor/gpui-component/crates/ui/src/index_path.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/blink_cursor.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/change.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/clear_button.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/cursor.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/README.md delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/display_map.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/fold_map.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/folding.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/text_wrapper.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/display_map/wrap_map.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/element.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/indent.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/input.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/code_actions.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/completions.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/definitions.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/document_colors.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/hover.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/lsp/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/mask_pattern.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/mode.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/movement.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/number_input.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/otp_input.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/popovers/code_action_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/popovers/completion_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/popovers/context_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/popovers/hover_popover.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/popovers/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/rope_ext.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/search.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/selection.rs delete mode 100644 vendor/gpui-component/crates/ui/src/input/state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/inspector.rs delete mode 100644 vendor/gpui-component/crates/ui/src/kbd.rs delete mode 100644 vendor/gpui-component/crates/ui/src/label.rs delete mode 100644 vendor/gpui-component/crates/ui/src/lib.rs delete mode 100644 vendor/gpui-component/crates/ui/src/link.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/cache.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/delegate.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/list.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/list_item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/loading.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/list/separator_item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/app_menu_bar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/context_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/dropdown_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/menu_item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/menu/popup_menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/notification.rs delete mode 100644 vendor/gpui-component/crates/ui/src/pagination.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/axis.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/grid.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/label.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale/band.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale/linear.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale/ordinal.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale/point.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/scale/sealed.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/arc.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/area.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/bar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/line.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/pie.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/shape/stack.rs delete mode 100644 vendor/gpui-component/crates/ui/src/plot/tooltip.rs delete mode 100644 vendor/gpui-component/crates/ui/src/popover.rs delete mode 100644 vendor/gpui-component/crates/ui/src/progress/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/progress/progress.rs delete mode 100644 vendor/gpui-component/crates/ui/src/progress/progress_circle.rs delete mode 100644 vendor/gpui-component/crates/ui/src/radio.rs delete mode 100644 vendor/gpui-component/crates/ui/src/rating.rs delete mode 100644 vendor/gpui-component/crates/ui/src/resizable/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/resizable/panel.rs delete mode 100644 vendor/gpui-component/crates/ui/src/resizable/resize_handle.rs delete mode 100644 vendor/gpui-component/crates/ui/src/root.rs delete mode 100644 vendor/gpui-component/crates/ui/src/scroll/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/scroll/scrollable.rs delete mode 100644 vendor/gpui-component/crates/ui/src/scroll/scrollable_mask.rs delete mode 100644 vendor/gpui-component/crates/ui/src/scroll/scrollbar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/adapter.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/change.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/delegate.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/searchable_list/vec.rs delete mode 100644 vendor/gpui-component/crates/ui/src/select.rs delete mode 100644 vendor/gpui-component/crates/ui/src/separator.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/bool.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/dropdown.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/element.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/number.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/fields/string.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/group.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/page.rs delete mode 100644 vendor/gpui-component/crates/ui/src/setting/settings.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sheet.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sidebar/footer.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sidebar/group.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sidebar/header.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sidebar/menu.rs delete mode 100644 vendor/gpui-component/crates/ui/src/sidebar/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/skeleton.rs delete mode 100644 vendor/gpui-component/crates/ui/src/slider.rs delete mode 100644 vendor/gpui-component/crates/ui/src/spinner.rs delete mode 100644 vendor/gpui-component/crates/ui/src/stepper/item.rs delete mode 100644 vendor/gpui-component/crates/ui/src/stepper/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/stepper/stepper.rs delete mode 100644 vendor/gpui-component/crates/ui/src/stepper/trigger.rs delete mode 100644 vendor/gpui-component/crates/ui/src/styled.rs delete mode 100644 vendor/gpui-component/crates/ui/src/switch.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tab/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tab/tab.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tab/tab_bar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/column.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/data_table.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/delegate.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/loading.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/table/table.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tag.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/document.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/format/html.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/format/html5minify/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/format/markdown.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/format/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/inline.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/node.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/state.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/style.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/text_view.rs delete mode 100644 vendor/gpui-component/crates/ui/src/text/utils.rs delete mode 100644 vendor/gpui-component/crates/ui/src/theme/color.rs delete mode 100644 vendor/gpui-component/crates/ui/src/theme/default-colors.json delete mode 100644 vendor/gpui-component/crates/ui/src/theme/default-theme.json delete mode 100644 vendor/gpui-component/crates/ui/src/theme/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/theme/registry.rs delete mode 100644 vendor/gpui-component/crates/ui/src/theme/schema.rs delete mode 100644 vendor/gpui-component/crates/ui/src/theme/theme_color.rs delete mode 100644 vendor/gpui-component/crates/ui/src/time/calendar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/time/date_picker.rs delete mode 100644 vendor/gpui-component/crates/ui/src/time/mod.rs delete mode 100644 vendor/gpui-component/crates/ui/src/time/utils.rs delete mode 100644 vendor/gpui-component/crates/ui/src/title_bar.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tooltip.rs delete mode 100644 vendor/gpui-component/crates/ui/src/tree.rs delete mode 100644 vendor/gpui-component/crates/ui/src/virtual_list.rs delete mode 100644 vendor/gpui-component/crates/ui/src/window_border.rs delete mode 100644 vendor/gpui-component/crates/ui/src/window_ext.rs diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml deleted file mode 100644 index 362ec798..00000000 --- a/.github/workflows/aur-publish.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Publish to AUR - -on: - release: - types: [published] - -jobs: - aur-publish-bin: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Get version from tag - id: version - run: echo "pkgver=${GITHUB_EVENT_RELEASE_TAG_NAME#v}" >> "$GITHUB_OUTPUT" - env: - GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} - - - name: Get tarball checksum - id: checksum - run: | - curl -sL "https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}/kosmos-linux-x86_64.tar.gz" -o kosmos.tar.gz - echo "sha256=$(sha256sum kosmos.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - - name: Update PKGBUILD - run: | - cd aur/kosmos-bin - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.pkgver }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${{ steps.checksum.outputs.sha256 }}')/" PKGBUILD - - - name: Publish to AUR - env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - run: | - mkdir -p ~/.ssh - echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts - cat >> ~/.ssh/config <<'SSH' - Host aur.archlinux.org - IdentityFile ~/.ssh/aur - User aur - SSH - - git clone ssh://aur@aur.archlinux.org/kosmos-bin.git aur-repo - cp aur/kosmos-bin/PKGBUILD aur-repo/PKGBUILD - - cd aur-repo - docker run --rm -v "$PWD:/pkg" -e HOST_UID="$(id -u)" archlinux:base bash -c \ - 'pacman -Sy --noconfirm pacman-contrib && useradd -m builder && chown -R builder /pkg && runuser -u builder -- bash -c "cd /pkg && makepkg --printsrcinfo > .SRCINFO" && chown -R $HOST_UID /pkg' - - git config user.name "Martin Etchebarne" - git config user.email "martin@etchebarne.net" - git add PKGBUILD .SRCINFO - git diff --cached --quiet && echo "No changes to commit" || git commit -m "Update to ${{ steps.version.outputs.pkgver }}" - git push - - aur-publish-src: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Get version from tag - id: version - run: echo "pkgver=${GITHUB_EVENT_RELEASE_TAG_NAME#v}" >> "$GITHUB_OUTPUT" - env: - GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} - - - name: Update PKGBUILD - run: | - cd aur/kosmos - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.pkgver }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - - - name: Publish to AUR - env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - run: | - mkdir -p ~/.ssh - echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts - cat >> ~/.ssh/config <<'SSH' - Host aur.archlinux.org - IdentityFile ~/.ssh/aur - User aur - SSH - - git clone ssh://aur@aur.archlinux.org/kosmos.git aur-repo - cp aur/kosmos/PKGBUILD aur-repo/PKGBUILD - - cd aur-repo - docker run --rm -v "$PWD:/pkg" -e HOST_UID="$(id -u)" archlinux:base bash -c \ - 'pacman -Sy --noconfirm pacman-contrib && useradd -m builder && chown -R builder /pkg && runuser -u builder -- bash -c "cd /pkg && makepkg --printsrcinfo > .SRCINFO" && chown -R $HOST_UID /pkg' - - git config user.name "Martin Etchebarne" - git config user.email "martin@etchebarne.net" - git add PKGBUILD .SRCINFO - git diff --cached --quiet && echo "No changes to commit" || git commit -m "Update to ${{ steps.version.outputs.pkgver }}" - git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 3dea31e2..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "Tag to build and release (e.g. v0.1.0)" - required: true - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Resolve tag - id: tag - run: echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT" - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential pkg-config gettext-base llvm clang libclang-dev curl rpm desktop-file-utils \ - libssl-dev libfontconfig-dev libxkbcommon-dev \ - libxcb1-dev libxkbcommon-x11-dev \ - libwayland-dev libvulkan-dev - - - uses: mlugg/setup-zig@v2 - with: - version: 0.16.0 - - - name: Install AppImage tooling - run: | - curl -fsSL \ - https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage \ - -o /tmp/appimagetool - chmod +x /tmp/appimagetool - sudo mv /tmp/appimagetool /usr/local/bin/appimagetool - - - uses: dtolnay/rust-toolchain@stable - - - uses: swatinem/rust-cache@v2 - - - name: Bundle - run: ./scripts/bundle-linux.sh - - - name: Package deb and rpm - run: ./scripts/package-linux-deb-rpm.sh - - - name: Package AppImage - run: ./scripts/package-linux-appimage.sh - - - name: Create GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - tag="${{ steps.tag.outputs.tag }}" - version="${tag#v}" - arch="$(uname -m)" - case "$arch" in - x86_64|amd64) - bundle_arch="x86_64" - deb_arch="amd64" - rpm_arch="x86_64" - appimage_arch="x86_64" - ;; - *) - echo "Unsupported release architecture: $arch" >&2 - exit 1 - ;; - esac - artifacts=( - "target/release/kosmos-linux-${bundle_arch}.tar.gz" - "target/release/kosmos_${version}_${deb_arch}.deb" - "target/release/kosmos-${version}-1.${rpm_arch}.rpm" - "target/release/Kosmos-${version}-${appimage_arch}.AppImage" - scripts/install.sh - scripts/uninstall.sh - ) - - if gh release view "$tag" >/dev/null 2>&1; then - gh release upload "$tag" "${artifacts[@]}" --clobber - else - gh release create "$tag" \ - --title "Kosmos $tag" \ - --draft \ - --generate-notes \ - "${artifacts[@]}" - fi diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index f527c0b0..00000000 --- a/Cargo.lock +++ /dev/null @@ -1,10524 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", - "zeroize", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "ai" -version = "0.4.9" - -[[package]] -name = "alacritty_terminal" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" -dependencies = [ - "base64", - "bitflags 2.11.1", - "home", - "libc", - "log", - "miow", - "parking_lot", - "piper", - "polling", - "regex-automata", - "rustix 1.1.4", - "rustix-openpty", - "serde", - "signal-hook", - "unicode-width", - "vte", - "windows-sys 0.59.0", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "app_shell" -version = "0.4.9" -dependencies = [ - "file_editor", - "file_tree", - "gpui", - "gpui-component", - "gpui_platform", - "icons", - "installer", - "pane_tree", - "persistence", - "registry", - "settings", - "shortcuts", - "tabs", - "terminal", - "theme", - "ui", - "workspace", - "zoom", -] - -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "ashpd" -version = "0.13.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "getrandom 0.4.2", - "serde", - "serde_repr", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "futures-io", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand 2.4.1", - "futures-lite 2.6.1", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite 2.6.1", - "once_cell", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.6.1", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel 2.5.0", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.4.1", - "futures-lite 2.6.1", - "rustix 1.1.4", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "async-process", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite 2.6.1", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-tar" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" -dependencies = [ - "async-std", - "filetime", - "libc", - "pin-project", - "redox_syscall 0.2.16", - "xattr", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async_zip" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" -dependencies = [ - "async-compression", - "crc32fast", - "futures-lite 2.6.1", - "pin-project", - "thiserror 2.0.18", -] - -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - -[[package]] -name = "base62" -version = "2.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.2", - "shlex", - "syn", -] - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.11.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.2", - "shlex", - "syn", -] - -[[package]] -name = "bit-set" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite 2.6.1", - "piper", -] - -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bytesize" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" - -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.11.1", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cbindgen" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" -dependencies = [ - "heck 0.4.1", - "indexmap", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "tempfile", - "toml 0.8.23", -] - -[[package]] -name = "cc" -version = "1.2.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation 0.1.2", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" -dependencies = [ - "bitflags 2.11.1", - "block", - "cocoa-foundation 0.2.0", - "core-foundation 0.10.0", - "core-graphics 0.24.0", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" -dependencies = [ - "bitflags 2.11.1", - "block", - "core-foundation 0.10.0", - "core-graphics-types 0.2.0", - "libc", - "objc", -] - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "indexmap", - "rustc-hash 2.1.2", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "command-fds" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" -dependencies = [ - "nix 0.31.2", - "thiserror 2.0.18", -] - -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "bzip2", - "compression-core", - "deflate64", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[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 = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.0", - "core-graphics-types 0.2.0", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-helmer-fork" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.0", - "libc", -] - -[[package]] -name = "core-graphics2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" -dependencies = [ - "bitflags 2.11.1", - "block", - "cfg-if", - "core-foundation 0.10.0", - "libc", -] - -[[package]] -name = "core-text" -version = "21.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" -dependencies = [ - "core-foundation 0.10.0", - "core-graphics 0.24.0", - "foreign-types", - "libc", -] - -[[package]] -name = "core-video" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" -dependencies = [ - "block", - "core-foundation 0.10.0", - "core-graphics2", - "io-surface", - "libc", - "metal", -] - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cosmic-text" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56" -dependencies = [ - "bitflags 2.11.1", - "fontdb", - "harfrust", - "linebender_resource_handle", - "log", - "rangemap", - "rustc-hash 2.1.2", - "self_cell", - "skrifa", - "smol_str", - "swash", - "sys-locale", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "ctor" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.1", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "doxygen-rs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" -dependencies = [ - "phf", -] - -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dwrote" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" -dependencies = [ - "lazy_static", - "libc", - "winapi", - "wio", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embed-resource" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.2+spec-1.1.0", - "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enum-iterator" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" -dependencies = [ - "enum-iterator-derive", -] - -[[package]] -name = "enum-iterator-derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etagere" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" -dependencies = [ - "euclid", - "svg_fmt", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.1", - "pin-project-lite", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "faster-hex" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" -dependencies = [ - "heapless 0.8.0", - "serde", -] - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "fff-grep" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68286213d07412846c0010a0b227b79c08fbc214cf7822f85cabd9e3f36606ae" -dependencies = [ - "bstr", - "memchr", -] - -[[package]] -name = "fff-notify-debouncer-full" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a4ebea7b8a2840cd59358bbf396f6f04313ce8eae84ac79703ce80298b8731" -dependencies = [ - "file-id", - "log", - "notify 9.0.0-rc.4", - "notify-types 2.1.0", - "rustc-hash 2.1.2", - "walkdir", -] - -[[package]] -name = "fff-query-parser" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5eb0a0363657b57a3a60d12a76a41f799406514b238123487e54290a6f1a62f" -dependencies = [ - "zlob", -] - -[[package]] -name = "fff-search" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b4517eb8a9b87a46d09e9958f2ddbc036440a9fb5d5ddfc05e6dcf4655ce2" -dependencies = [ - "ahash", - "aho-corasick", - "blake3", - "dirs 5.0.1", - "dunce", - "fff-grep", - "fff-notify-debouncer-full", - "fff-query-parser", - "git2", - "glidesort", - "globset", - "heed", - "ignore", - "libc", - "memchr", - "memmap2", - "neo_frizbee", - "notify 9.0.0-rc.4", - "parking_lot", - "pathdiff", - "rayon", - "regex", - "regex-syntax", - "serde", - "smallvec", - "thiserror 2.0.18", - "tracing", - "tracing-appender", - "tracing-subscriber", - "zlob", -] - -[[package]] -name = "file-id" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" -dependencies = [ - "windows-sys 0.60.2", -] - -[[package]] -name = "file_editor" -version = "0.4.9" -dependencies = [ - "gpui", - "language", - "settings", -] - -[[package]] -name = "file_search" -version = "0.4.9" -dependencies = [ - "fff-search", -] - -[[package]] -name = "file_tree" -version = "0.4.9" -dependencies = [ - "gpui", - "notify 8.2.0", - "trash", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - -[[package]] -name = "float_next_after" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin 0.9.8", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "freetype-sys" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset", - "futures-core", - "futures-lite 2.6.1", - "pin-project", - "smallvec", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand 2.4.1", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link 0.2.1", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "git" -version = "0.4.9" -dependencies = [ - "gix", -] - -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libgit2-sys", - "log", - "url", -] - -[[package]] -name = "gix" -version = "0.82.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62786b0500a7b8dfd998b5c9fc343e1133dc3804293db98e787f5442e8003591" -dependencies = [ - "gix-actor", - "gix-archive", - "gix-attributes", - "gix-blame", - "gix-command", - "gix-commitgraph", - "gix-config", - "gix-credentials", - "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-mailmap", - "gix-merge", - "gix-negotiate", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-pathspec", - "gix-prompt", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-url", - "gix-utils", - "gix-validate", - "gix-worktree", - "gix-worktree-state", - "gix-worktree-stream", - "nonempty", - "parking_lot", - "regex", - "signal-hook", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-actor" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552ddba0ea986ef262ee3296a6464194181f20882902c8a7669515eaf1b0891e" -dependencies = [ - "bstr", - "gix-date", - "gix-error", - "winnow 1.0.2", -] - -[[package]] -name = "gix-archive" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af31eddd8d8842dc05e0ae1f35721f12484d2eaab7d989f183182280f2cc04af" -dependencies = [ - "bstr", - "gix-date", - "gix-error", - "gix-object", - "gix-worktree-stream", -] - -[[package]] -name = "gix-attributes" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac00bd435a36fcc518640dad4eca4045e1a2f0b33f74a2bbff58245f8e3744d" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-quote", - "gix-trace", - "kstring", - "smallvec", - "thiserror 2.0.18", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecbfc77ec6852294e341ecc305a490b59f2813e6ca42d79efda5099dcab1894" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-blame" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5789a39b8638b73e5c1210375910145bcf688f1786b7f71fbb1ff4cd51dc7d" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-diff", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", - "gix-trace", - "gix-traverse", - "gix-worktree", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-chunk" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf288be9b60fe7231de03771faa292be1493d84786f68727e33ad1f91764320" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-command" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae4bb9fa74c44c93f7238b08255f7f9afc158bafea4b95af665fa535352cd73c" -dependencies = [ - "bstr", - "gix-path", - "gix-quote", - "gix-trace", - "shell-words", -] - -[[package]] -name = "gix-commitgraph" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9dd13b8254d36049e9d1758657e74918a49de0286ec053d02497448fa215246" -dependencies = [ - "bstr", - "gix-chunk", - "gix-error", - "gix-hash", - "memmap2", - "nonempty", -] - -[[package]] -name = "gix-config" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ecbd673d9223a46e6bb766e0802ad9921de3541d95d121a9d05de5e23c8de" -dependencies = [ - "bstr", - "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", - "memchr", - "smallvec", - "thiserror 2.0.18", - "unicode-bom", - "winnow 1.0.2", -] - -[[package]] -name = "gix-config-value" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4378c53ec3db049919edf91ff76f56f28886a8b4b4a5a9dc633108d84afc3675" -dependencies = [ - "bitflags 2.11.1", - "bstr", - "gix-path", - "libc", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-credentials" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0493f25da3ce9e8f7d925e5b64dc6d0b6109c96add422a6bcada52416448147d" -dependencies = [ - "bstr", - "gix-command", - "gix-config-value", - "gix-date", - "gix-path", - "gix-prompt", - "gix-sec", - "gix-trace", - "gix-url", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-date" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc99523b8bf32561b9abf72c878fbff3854d806ed46c1198e57899f9f3c7f05" -dependencies = [ - "bstr", - "gix-error", - "itoa", - "jiff", - "smallvec", -] - -[[package]] -name = "gix-diff" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709e2b8c52e027d553e200d07f39865b8d9799004160ea609459dc73c48f357a" -dependencies = [ - "bstr", - "gix-attributes", - "gix-command", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-dir" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5590f35265d28cc65faa0d36896ae467836e989b0e790dd94d51d2417e768c" -dependencies = [ - "bstr", - "gix-discover", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-discover" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31885140cb036e25742275f9848b9266a54d553103caec9f3546c62126214f45" -dependencies = [ - "bstr", - "dunce", - "gix-fs", - "gix-path", - "gix-ref", - "gix-sec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-error" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c998bf10447f0797e579567382b5e22a19c22435d2df091e25857728c6d9af8d" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-features" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0f40b8c98b4b592a7e9c83fe79eacbfcdae8485aa6148ff15e52a4b6e9fe30" -dependencies = [ - "bytes", - "bytesize", - "crc32fast", - "crossbeam-channel", - "gix-path", - "gix-trace", - "gix-utils", - "libc", - "once_cell", - "parking_lot", - "prodash", - "thiserror 2.0.18", - "walkdir", - "zlib-rs", -] - -[[package]] -name = "gix-filter" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb4376a18f26bdc0fe88a719574c749a678074beda924571e24c5a83bb26e65" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash", - "gix-object", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-fs" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7464e9f4167e98d202c7cfb0b5ccbc170c6069870afc52bfd533ad265ce19872" -dependencies = [ - "bstr", - "fastrand 2.4.1", - "gix-features", - "gix-path", - "gix-utils", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-glob" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76beed0974ea539df93e44d10a6c7df0a9554f6e184dd730c33e65dc0e089614" -dependencies = [ - "bitflags 2.11.1", - "bstr", - "gix-features", - "gix-path", -] - -[[package]] -name = "gix-hash" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e3c92d6aa7a81bde221f3448e62b0e8d81a7d8086e85ccc24b3a4d54e315f30" -dependencies = [ - "faster-hex", - "gix-features", - "sha1-checked", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-hashtable" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08fe1e638b9b84b11eadfe60bdf5523bf46c40b3ef297e612a4a365832d3c8d3" -dependencies = [ - "gix-hash", - "hashbrown 0.16.1", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f72fe2033f4cf8f784fb413c15c8d1124e84f1ddf4d292ffc8468b05d329a047" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-imara-diff" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfef60ebcd45e0fad3007d15b45a305e6a25af9a0fdffbca0b02bf8b0f91f83c" -dependencies = [ - "bstr", - "hashbrown 0.16.1", - "memchr", -] - -[[package]] -name = "gix-index" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806c10a84ef9e0e2955ec678b7ba157b9bec1185e6d5c200e14d3496f44e3874" -dependencies = [ - "bitflags 2.11.1", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-traverse", - "gix-utils", - "gix-validate", - "hashbrown 0.16.1", - "itoa", - "libc", - "memmap2", - "rustix 1.1.4", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-lock" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e30507ef152e30cec1d0f70cd00199fac746e3d448c3b39c9dade8446fa1da" -dependencies = [ - "gix-tempfile", - "gix-utils", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-mailmap" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef39d850c0c6621851f4fdca0074faa6feb2b03e4a1a55e9dc00aa3144b99f41" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-error", -] - -[[package]] -name = "gix-merge" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8305849c022c954af46029756ffb3afcabcaf0be13a3cf3e8bfd204f1fc2b48" -dependencies = [ - "bstr", - "gix-command", - "gix-diff", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-quote", - "gix-revision", - "gix-revwalk", - "gix-tempfile", - "gix-trace", - "gix-worktree", - "nonempty", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-negotiate" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7cfa5d5f56dea56f96ced9ea55976b66ce8580e7fbdd23f25e9c99bd3e90927" -dependencies = [ - "bitflags 2.11.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", -] - -[[package]] -name = "gix-object" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710b16fe38cd3654ec218dd89911100648cd3f495e3997a1bbdc2af69ebf525a" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-utils", - "gix-validate", - "itoa", - "smallvec", - "thiserror 2.0.18", - "winnow 1.0.2", -] - -[[package]] -name = "gix-odb" -version = "0.79.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7080f6ebcb0597c2c8bf7e3e0fd02c4df80260c4bfc50a8427f659129a9067b5" -dependencies = [ - "arc-swap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", - "gix-quote", - "memmap2", - "parking_lot", - "tempfile", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-pack" -version = "0.69.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3f76daacd0e91a523dc741c4ef7b9355da67fd73167db831db1edd4720768b4" -dependencies = [ - "clru", - "gix-chunk", - "gix-error", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-path", - "memmap2", - "smallvec", - "thiserror 2.0.18", - "uluru", -] - -[[package]] -name = "gix-packetline" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "362246df440ee691699f0664cbf7006a6ece477db6734222be95e4198e5656e6" -dependencies = [ - "bstr", - "faster-hex", - "gix-trace", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-path" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fd1fe596dc393b538e1d5492c5585971a9311475b3255f7b889023df208476" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-pathspec" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d902b0cafb2b691738c585f0be98b1b73fad63644c6a612b9b2708bb7b1d17d" -dependencies = [ - "bitflags 2.11.1", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-prompt" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de52c2570684db3eb8cebda77c1d0e3d03ddaad2d9bbb5055eba31fb9f8292a" -dependencies = [ - "gix-command", - "gix-config-value", - "parking_lot", - "rustix 1.1.4", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-protocol" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565eaa4df8438f2919135284410284c99100a2f9836d89efd918292107a80165" -dependencies = [ - "bstr", - "gix-date", - "gix-features", - "gix-hash", - "gix-ref", - "gix-shallow", - "gix-transport", - "gix-utils", - "maybe-async", - "nonempty", - "thiserror 2.0.18", - "winnow 1.0.2", -] - -[[package]] -name = "gix-quote" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e97b73791a64bc0fa7dd2c5b3e551136115f97750b876ed1c952c7a7dbaf8be" -dependencies = [ - "bstr", - "gix-error", - "gix-utils", -] - -[[package]] -name = "gix-ref" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a4367b66548864dd16873c6c86220b81960994deb63965803d773e377e3fce4" -dependencies = [ - "gix-actor", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "memmap2", - "thiserror 2.0.18", - "winnow 1.0.2", -] - -[[package]] -name = "gix-refspec" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2b2756c9ea849bf63d01c0407be7006ebff78e6893e1845a56446eaeada359" -dependencies = [ - "bstr", - "gix-error", - "gix-glob", - "gix-hash", - "gix-revision", - "gix-validate", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-revision" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c130f74ac580ef8d37d723576b776674fdeb405f8b6126692c28346fdb85ac" -dependencies = [ - "bitflags 2.11.1", - "bstr", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "gix-trace", - "nonempty", -] - -[[package]] -name = "gix-revwalk" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96e8ba5213c036d064034cefa6349cba3628498bccd8eca3e22a121b1a6e726" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-sec" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "283f4a746c9bde8550be63e6f961ff4651f412ca12666e8f5615f39464960ab9" -dependencies = [ - "bitflags 2.11.1", - "gix-path", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "gix-shallow" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3290e102e9375812e6baddf3354357f07f04baeaece97e045f9dad2c7ce6a033" -dependencies = [ - "bstr", - "gix-hash", - "gix-lock", - "nonempty", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-status" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a444038e1bd06039464990f0653265d0707c5d92dda283cdf7cecd1b1f3182ba" -dependencies = [ - "bstr", - "filetime", - "gix-diff", - "gix-dir", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-worktree", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-submodule" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5099295499844e2d9d06d3e9be91d3db67fe322b9bdacaa4009769059affec02" -dependencies = [ - "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-tempfile" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30bb168ab673020410158264e21b267dae3b89d248e901400b1cba788fcba7a" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "signal-hook", - "signal-hook-registry", - "tempfile", -] - -[[package]] -name = "gix-trace" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f23569e55f2ffaf958617353b9734a7d52a7c19c439eeaa5e3efc217fd2270e" - -[[package]] -name = "gix-transport" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f7cc0a5f8ff289c2d18077740efbad216d8c85b949c40827cd5bff00e457df" -dependencies = [ - "bstr", - "gix-command", - "gix-features", - "gix-packetline", - "gix-quote", - "gix-sec", - "gix-url", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-traverse" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef81f53b86d0cb1588768aaea171241fb98818c8893dd43aa3343fff49600e09" -dependencies = [ - "bitflags 2.11.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-url" -version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a61ead12e33fa52ae92b207ee27554f646a8e7a3dad8b78da1582ec91eda0a6" -dependencies = [ - "bstr", - "gix-path", - "percent-encoding", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-utils" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e477b4f07a6e8da4ba791c53c858102959703c60d70f199932010d5b94adb2c" -dependencies = [ - "bstr", - "fastrand 2.4.1", - "unicode-normalization", -] - -[[package]] -name = "gix-validate" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e26ac2602b43eadfdca0560b81d3341944162a3c9f64ccdeef8fc501ad80dad5" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-worktree" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f16b89b6195beba7e5517fea8ba20ca6fca67143fb1b070560ce0a4a866c87a" -dependencies = [ - "bstr", - "gix-attributes", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate", -] - -[[package]] -name = "gix-worktree-state" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acaa094bfbba896270a83b19585a0ee4ee83a0a64ab0ed3027302a3425436b3f" -dependencies = [ - "bstr", - "gix-features", - "gix-filter", - "gix-fs", - "gix-index", - "gix-object", - "gix-path", - "gix-worktree", - "io-close", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-worktree-stream" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e0dc22458124d74729003a95225bd63236652671d660a8134843bedc1d4600" -dependencies = [ - "gix-attributes", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-traverse", - "parking_lot", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glidesort" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" -dependencies = [ - "bitflags 1.3.2", - "ignore", - "walkdir", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "glow" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-allocator" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" -dependencies = [ - "ash", - "hashbrown 0.16.1", - "log", - "presser", - "thiserror 2.0.18", - "windows 0.62.2", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.11.1", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "async-channel 2.5.0", - "async-task", - "bindgen 0.71.1", - "bitflags 2.11.1", - "block", - "cbindgen", - "chrono", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", - "collections", - "core-foundation 0.10.0", - "core-foundation-sys", - "core-graphics 0.24.0", - "core-text", - "core-video", - "ctor", - "derive_more", - "embed-resource", - "etagere", - "foreign-types", - "futures", - "futures-concurrency", - "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", - "http_client", - "image", - "inventory", - "itertools 0.14.0", - "log", - "lyon", - "mach2", - "media", - "metal", - "num_cpus", - "objc", - "parking", - "parking_lot", - "pathfinder_geometry", - "pin-project", - "pollster 0.4.0", - "postage", - "profiling", - "rand 0.9.4", - "raw-window-handle", - "refineable", - "regex", - "resvg", - "scheduler", - "schemars", - "seahash", - "serde", - "serde_json", - "slotmap", - "smallvec", - "spin 0.10.0", - "stacksafe", - "strum", - "sum_tree", - "taffy", - "thiserror 2.0.18", - "ttf-parser", - "url", - "usvg", - "util_macros", - "uuid", - "waker-fn", - "web-time", - "windows 0.61.3", - "zed-font-kit", - "zed-scap", -] - -[[package]] -name = "gpui-component" -version = "0.5.2" -dependencies = [ - "aho-corasick", - "anyhow", - "async-channel 2.5.0", - "chrono", - "core-text", - "enum-iterator", - "futures", - "gpui", - "gpui-component-macros", - "gpui_macros", - "html5ever", - "instant", - "itertools 0.13.0", - "log", - "lsp-types", - "markdown", - "markup5ever_rcdom", - "notify 7.0.0", - "num-traits", - "once_cell", - "paste", - "regex", - "ropey", - "rust-i18n", - "schemars", - "serde", - "serde_json", - "serde_repr", - "smallvec", - "smol", - "tracing", - "tree-sitter", - "tree-sitter-astro-next", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-c-sharp", - "tree-sitter-cmake", - "tree-sitter-cpp", - "tree-sitter-css", - "tree-sitter-diff", - "tree-sitter-elixir", - "tree-sitter-embedded-template", - "tree-sitter-go", - "tree-sitter-graphql", - "tree-sitter-html", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-jsdoc", - "tree-sitter-json", - "tree-sitter-kotlin-sg", - "tree-sitter-lua", - "tree-sitter-make", - "tree-sitter-md", - "tree-sitter-php", - "tree-sitter-proto", - "tree-sitter-python", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-scala", - "tree-sitter-sequel", - "tree-sitter-svelte-next", - "tree-sitter-swift", - "tree-sitter-toml-ng", - "tree-sitter-typescript", - "tree-sitter-xml", - "tree-sitter-yaml", - "tree-sitter-zig", - "unicode-segmentation", - "uuid", - "zed-sum-tree", -] - -[[package]] -name = "gpui-component-macros" -version = "0.5.1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gpui_linux" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "as-raw-xcb-connection", - "ashpd", - "bitflags 2.11.1", - "bytemuck", - "calloop", - "calloop-wayland-source", - "collections", - "filedescriptor", - "futures", - "gpui", - "gpui_wgpu", - "http_client", - "image", - "itertools 0.14.0", - "libc", - "log", - "oo7", - "open", - "parking_lot", - "pathfinder_geometry", - "pollster 0.4.0", - "profiling", - "raw-window-handle", - "smallvec", - "smol", - "strum", - "swash", - "url", - "util", - "uuid", - "wayland-backend", - "wayland-client", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-plasma", - "wayland-protocols-wlr", - "x11-clipboard", - "x11rb", - "xkbcommon", - "zed-scap", - "zed-xim", -] - -[[package]] -name = "gpui_macos" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "async-task", - "block", - "cbindgen", - "cocoa 0.26.0", - "collections", - "core-foundation 0.10.0", - "core-foundation-sys", - "core-graphics 0.24.0", - "core-text", - "core-video", - "ctor", - "derive_more", - "dispatch2", - "etagere", - "foreign-types", - "futures", - "gpui", - "image", - "itertools 0.14.0", - "libc", - "log", - "mach2", - "media", - "metal", - "objc", - "objc2-app-kit", - "parking_lot", - "pathfinder_geometry", - "raw-window-handle", - "semver", - "smallvec", - "strum", - "util", - "uuid", - "zed-font-kit", -] - -[[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gpui_platform" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "console_error_panic_hook", - "gpui", - "gpui_linux", - "gpui_macos", - "gpui_web", - "gpui_windows", -] - -[[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "schemars", - "serde", - "smol_str", -] - -[[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "log", -] - -[[package]] -name = "gpui_web" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "console_error_panic_hook", - "futures", - "gpui", - "gpui_wgpu", - "http_client", - "js-sys", - "log", - "parking_lot", - "raw-window-handle", - "smallvec", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm_thread", - "web-sys", - "web-time", -] - -[[package]] -name = "gpui_wgpu" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "bytemuck", - "collections", - "cosmic-text", - "etagere", - "gpui", - "gpui_util", - "itertools 0.14.0", - "js-sys", - "log", - "parking_lot", - "pollster 0.4.0", - "profiling", - "raw-window-handle", - "smallvec", - "swash", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu", - "zed-font-kit", -] - -[[package]] -name = "gpui_windows" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "collections", - "etagere", - "futures", - "gpui", - "image", - "itertools 0.14.0", - "log", - "parking_lot", - "rand 0.9.4", - "raw-window-handle", - "smallvec", - "util", - "uuid", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry", -] - -[[package]] -name = "grid" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "harfrust" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" -dependencies = [ - "bitflags 2.11.1", - "bytemuck", - "core_maths", - "read-fonts", - "smallvec", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "heed" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" -dependencies = [ - "bitflags 2.11.1", - "byteorder", - "heed-traits", - "heed-types", - "libc", - "lmdb-master-sys", - "once_cell", - "page_size", - "serde", - "synchronoise", - "url", -] - -[[package]] -name = "heed-traits" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" - -[[package]] -name = "heed-types" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" -dependencies = [ - "bincode", - "byteorder", - "heed-traits", - "serde", - "serde_json", -] - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "html5ever" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" -dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "async-compression", - "async-fs", - "async-tar", - "bytes", - "derive_more", - "futures", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "sha2", - "tempfile", - "url", - "util", -] - -[[package]] -name = "human_format" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icondata_core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c97be924215abd5e630d84e95a47c710138a6559b4c55039f4f33aa897fa859" - -[[package]] -name = "icondata_vs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2547385392ec350a5a7f80c463767b1d7f7a37cf1431cac2754918a4c1b6a9" -dependencies = [ - "icondata_core", -] - -[[package]] -name = "icons" -version = "0.4.9" -dependencies = [ - "gpui", - "icondata_core", - "icondata_vs", - "image", - "resvg", - "smallvec", - "theme", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif 0.14.2", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imagesize" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.1", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "installer" -version = "0.4.9" -dependencies = [ - "flate2", - "registry", - "ureq", - "zip", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "io-close" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "io-surface" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" -dependencies = [ - "cgl", - "core-foundation 0.10.0", - "core-foundation-sys", - "leaky-cow", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" -dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-sys 0.61.2", -] - -[[package]] -name = "jiff-static" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "kosmos" -version = "0.4.9" -dependencies = [ - "app_shell", -] - -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - -[[package]] -name = "kstring" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "kurbo" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - -[[package]] -name = "language" -version = "0.4.9" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leak" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" - -[[package]] -name = "leaky-cow" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" -dependencies = [ - "leak", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libgit2-sys" -version = "0.18.5+1.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags 2.11.1", - "libc", - "plain", - "redox_syscall 0.7.4", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lmdb-master-sys" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" -dependencies = [ - "cc", - "doxygen-rs", - "libc", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "serde_core", - "value-bag", -] - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lsp" -version = "0.4.9" -dependencies = [ - "installer", - "lsp-types", - "registry", - "serde_json", - "url", -] - -[[package]] -name = "lsp-types" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" -dependencies = [ - "bitflags 1.3.2", - "fluent-uri", - "serde", - "serde_json", - "serde_repr", -] - -[[package]] -name = "lyon" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" -dependencies = [ - "lyon_algorithms", - "lyon_tessellation", -] - -[[package]] -name = "lyon_algorithms" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9815fac08e6fd96733a11dce4f9d15a3f338e96a2e2311ee21e1b738efc2bc0f" -dependencies = [ - "lyon_path", - "num-traits", -] - -[[package]] -name = "lyon_geom" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" -dependencies = [ - "lyon_geom", - "num-traits", -] - -[[package]] -name = "lyon_tessellation" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" -dependencies = [ - "float_next_after", - "lyon_path", - "num-traits", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "mach2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" -dependencies = [ - "libc", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "markdown" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" -dependencies = [ - "serde", - "unicode-id", -] - -[[package]] -name = "markup5ever" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "maybe-async" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "media" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "bindgen 0.71.1", - "core-foundation 0.10.0", - "core-video", - "ctor", - "foreign-types", - "metal", - "objc", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "metal" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" -dependencies = [ - "bitflags 2.11.1", - "block", - "core-graphics-types 0.2.0", - "foreign-types", - "log", - "objc", - "paste", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "miow" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "naga" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.2.1", - "codespan-reporting", - "half", - "hashbrown 0.16.1", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.18", - "unicode-ident", -] - -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "neo_frizbee" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728e0731ad3a0083b9f72a82df72c298641ba970d4e308e0897ec4c89212c31d" -dependencies = [ - "itertools 0.14.0", - "raw-cpuid", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - -[[package]] -name = "nix" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - -[[package]] -name = "no_std_io2" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" -dependencies = [ - "memchr", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nonempty" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "normpath" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.11.1", - "filetime", - "fsevent-sys", - "inotify 0.10.2", - "kqueue", - "libc", - "log", - "mio", - "notify-types 1.0.1", - "walkdir", - "windows-sys 0.52.0", -] - -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.1", - "fsevent-sys", - "inotify 0.11.1", - "kqueue", - "libc", - "log", - "mio", - "notify-types 2.1.0", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify" -version = "9.0.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" -dependencies = [ - "bitflags 2.11.1", - "inotify 0.11.1", - "kqueue", - "libc", - "log", - "mio", - "notify-types 2.1.0", - "objc2-core-foundation", - "objc2-core-services", - "walkdir", - "windows-sys 0.61.2", - "xxhash-rust", -] - -[[package]] -name = "notify-types" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" -dependencies = [ - "instant", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" -dependencies = [ - "libm", - "num-integer", - "num-iter", - "num-traits", - "once_cell", - "rand 0.9.4", - "serde", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-services" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.11.1", - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "oo7" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" -dependencies = [ - "aes", - "ashpd", - "async-fs", - "async-io", - "async-lock", - "blocking", - "cbc", - "cipher", - "digest", - "endi", - "futures-lite 2.6.1", - "futures-util", - "getrandom 0.4.2", - "hkdf", - "hmac", - "md-5", - "num", - "num-bigint-dig", - "pbkdf2", - "serde", - "serde_bytes", - "sha2", - "subtle", - "zbus", - "zbus_macros", - "zeroize", - "zvariant", -] - -[[package]] -name = "open" -version = "5.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "pane_tree" -version = "0.4.9" -dependencies = [ - "panes", - "tabs", -] - -[[package]] -name = "panes" -version = "0.4.9" -dependencies = [ - "tabs", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pathfinder_geometry" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" -dependencies = [ - "log", - "pathfinder_simd", -] - -[[package]] -name = "pathfinder_simd" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" -dependencies = [ - "rustc_version", -] - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "perf" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "collections", - "serde", - "serde_json", -] - -[[package]] -name = "persistence" -version = "0.4.9" -dependencies = [ - "gpui", - "pane_tree", - "panes", - "rusqlite", - "storage", - "tabs", - "workspace", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand 2.4.1", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic", - "crossbeam-queue", - "futures", - "log", - "parking_lot", - "pin-project", - "pollster 0.2.5", - "static_assertions", - "thiserror 1.0.69", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prodash" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" -dependencies = [ - "bytesize", - "human_format", - "parking_lot", -] - -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "psm" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "raw-window-metal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" -dependencies = [ - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" -dependencies = [ - "bytemuck", - "core_maths", - "font-types", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "derive_refineable", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "registry" -version = "0.4.9" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "resvg" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" -dependencies = [ - "gif 0.13.3", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia", - "usvg", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "ropey" -version = "2.0.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" -dependencies = [ - "str_indices", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.11.1", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "globset", - "sha2", - "walkdir", -] - -[[package]] -name = "rust-i18n" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21031bf5e6f2c0ae745d831791c403608e99a8bd3776c7e5e5535acd70c3b7ba" -dependencies = [ - "globwalk", - "regex", - "rust-i18n-macro", - "rust-i18n-support", - "smallvec", -] - -[[package]] -name = "rust-i18n-macro" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fe5295763b358606f7ca26a564e20f4469775a57ec1f09431249a33849ff52" -dependencies = [ - "glob", - "proc-macro2", - "quote", - "rust-i18n-support", - "serde", - "serde_json", - "serde_yaml", - "syn", -] - -[[package]] -name = "rust-i18n-support" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69bcc115c8eea2803aa3d85362e339776f4988a0349f2f475af572e497443f6f" -dependencies = [ - "arc-swap", - "base62", - "globwalk", - "itertools 0.11.0", - "lazy_static", - "normpath", - "proc-macro2", - "regex", - "serde", - "serde_json", - "serde_yaml", - "siphasher", - "toml 0.8.23", - "triomphe", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustix-openpty" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" -dependencies = [ - "errno", - "libc", - "rustix 1.1.4", -] - -[[package]] -name = "rustls" -version = "0.23.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.11.1", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume", - "futures", - "parking_lot", - "rand 0.9.4", - "web-time", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "indexmap", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "screencapturekit" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" -dependencies = [ - "screencapturekit-sys", -] - -[[package]] -name = "screencapturekit-sys" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" -dependencies = [ - "block", - "dispatch", - "objc", - "objc-foundation", - "objc_id", - "once_cell", -] - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_fmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "indexmap", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_json_lenient" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" -dependencies = [ - "indexmap", - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "serial2" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" -dependencies = [ - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "settings" -version = "0.4.9" -dependencies = [ - "gpui", - "icons", - "rusqlite", - "serde_json", - "storage", - "theme", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest", - "sha1", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shortcuts" -version = "0.4.9" -dependencies = [ - "gpui", -] - -[[package]] -name = "signal-hook" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "skrifa" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" -dependencies = [ - "bytemuck", - "read-fonts", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "smol" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-fs", - "async-io", - "async-lock", - "async-net", - "async-process", - "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spirv" -version = "0.4.0+sdk-1.4.341.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacker" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - -[[package]] -name = "stacksafe" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" -dependencies = [ - "stacker", - "stacksafe-macro", -] - -[[package]] -name = "stacksafe-macro" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" -dependencies = [ - "proc-macro-error2", - "quote", - "syn", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "storage" -version = "0.4.9" -dependencies = [ - "rusqlite", -] - -[[package]] -name = "str_indices" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" - -[[package]] -name = "streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "heapless 0.9.3", - "log", - "rayon", - "tracing", - "ztracing", -] - -[[package]] -name = "sval" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb9318255ebd817902d7e279d8f8e39b35b1b9954decd5eb9ea0e30e5fd2b6a" - -[[package]] -name = "sval_buffer" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12571299185e653fdb0fbfe36cd7f6529d39d4e747a60b15a3f34574b7b97c61" -dependencies = [ - "sval", - "sval_ref", -] - -[[package]] -name = "sval_dynamic" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39526f24e997706c0de7f03fb7371f7f5638b66a504ded508e20ad173d0a3677" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_fmt" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "933dd3bb26965d682280fcc49400ac2a05036f4ee1e6dbd61bf8402d5a5c3a54" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_json" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0cda08f6d5c9948024a6551077557b1fdcc3880ff2f20ae839667d2ec2d87ed" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_nested" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d49d5e6c1f9fd0e53515819b03a97ca4eb1bff5c8ee097c43391c09ecfb19f" -dependencies = [ - "sval", - "sval_buffer", - "sval_ref", -] - -[[package]] -name = "sval_ref" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f876c5a78405375b4e19cbb9554407513b59c93dea12dc6a4af4e1d30899ca" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_serde" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f9ccd3b7f7200239a655e517dd3fd48d960b9111ad24bd6a5e055bef17607c7" -dependencies = [ - "serde_core", - "sval", - "sval_nested", -] - -[[package]] -name = "svg_fmt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" - -[[package]] -name = "svgtypes" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] -name = "swash" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" -dependencies = [ - "skrifa", - "yazi", - "zeno", -] - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synchronoise" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" -dependencies = [ - "crossbeam-queue", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - -[[package]] -name = "sysinfo" -version = "0.31.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.56.0", -] - -[[package]] -name = "tabs" -version = "0.4.9" - -[[package]] -name = "taffy" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" -dependencies = [ - "arrayvec", - "grid", - "serde", - "slotmap", -] - -[[package]] -name = "take-until" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" - -[[package]] -name = "tao-core-video-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "objc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand 2.4.1", - "getrandom 0.4.2", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "terminal" -version = "0.4.9" -dependencies = [ - "alacritty_terminal", - "gpui", - "portable-pty", -] - -[[package]] -name = "theme" -version = "0.4.9" -dependencies = [ - "gpui", - "gpui-component", - "serde_json", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "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 = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.17.16", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.2", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.2", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.2", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "trash" -version = "5.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" -dependencies = [ - "chrono", - "libc", - "log", - "objc2", - "objc2-foundation", - "once_cell", - "percent-encoding", - "scopeguard", - "urlencoding", - "windows 0.56.0", -] - -[[package]] -name = "tree-sitter" -version = "0.26.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-astro-next" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-bash" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-c" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-c-sharp" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-cmake" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c1b35d1dd7396d24b3e826bb0f975b915ec7e9125b989d5e9d24ebb6a08509a" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-cpp" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-css" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-diff" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-elixir" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-embedded-template" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-go" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-graphql" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-html" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-java" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-javascript" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-jsdoc" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-json" -version = "0.24.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-kotlin-sg" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" - -[[package]] -name = "tree-sitter-lua" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-make" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-md" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-php" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-proto" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-python" -version = "0.23.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-ruby" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-rust" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-scala" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-sequel" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-svelte-next" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-swift" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-toml-ng" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-typescript" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-xml" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-yaml" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-zig" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "triomphe" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" -dependencies = [ - "arc-swap", - "serde", - "stable_deref_trait", -] - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "ui" -version = "0.4.9" -dependencies = [ - "anyhow", - "file_editor", - "file_search", - "file_tree", - "git", - "gpui", - "gpui-component", - "icons", - "installer", - "language", - "lsp", - "lsp-types", - "pane_tree", - "panes", - "registry", - "settings", - "shortcuts", - "tabs", - "terminal", - "theme", - "workspace", -] - -[[package]] -name = "uluru" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-bom" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - -[[package]] -name = "unicode-id" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "usvg" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" -dependencies = [ - "base64", - "data-url", - "flate2", - "fontdb", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree", - "rustybuzz", - "simplecss", - "siphasher", - "strict-num", - "svgtypes", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "async-fs", - "async_zip", - "collections", - "command-fds", - "dirs 6.0.0", - "dunce", - "futures", - "futures-lite 1.13.0", - "globset", - "gpui_util", - "itertools 0.14.0", - "libc", - "log", - "mach2", - "nix 0.29.0", - "percent-encoding", - "regex", - "rust-embed", - "schemars", - "serde", - "serde_json", - "serde_json_lenient", - "shlex", - "smol", - "take-until", - "tempfile", - "tendril", - "unicase", - "url", - "walkdir", - "which", -] - -[[package]] -name = "util_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "perf", - "quote", - "syn", -] - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" -dependencies = [ - "value-bag-serde1", - "value-bag-sval2", -] - -[[package]] -name = "value-bag-serde1" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5" -dependencies = [ - "erased-serde", - "serde_core", - "serde_fmt", -] - -[[package]] -name = "value-bag-sval2" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f" -dependencies = [ - "sval", - "sval_buffer", - "sval_dynamic", - "sval_fmt", - "sval_json", - "sval_ref", - "sval_serde", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "vte" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" -dependencies = [ - "arrayvec", - "bitflags 2.11.1", - "cursor-icon", - "log", - "memchr", - "serde", -] - -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm_thread" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" -dependencies = [ - "futures", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.11.1", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.4", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.11.1", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.11.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.11.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml 0.39.4", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "arrayvec", - "bitflags 2.11.1", - "bytemuck", - "cfg-if", - "cfg_aliases 0.2.1", - "document-features", - "hashbrown 0.16.1", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.11.1", - "bytemuck", - "cfg_aliases 0.2.1", - "document-features", - "hashbrown 0.16.1", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.11.1", - "block2", - "bytemuck", - "cfg-if", - "cfg_aliases 0.2.1", - "glow", - "glutin_wgl_sys", - "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", - "js-sys", - "khronos-egl", - "libc", - "libloading", - "log", - "naga", - "ndk-sys", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "once_cell", - "ordered-float", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "raw-window-metal", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", - "wasm-bindgen", - "wayland-sys", - "web-sys", - "wgpu-naga-bridge", - "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", - "windows-result 0.4.1", -] - -[[package]] -name = "wgpu-naga-bridge" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "naga", - "wgpu-types", -] - -[[package]] -name = "wgpu-types" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "bitflags 2.11.1", - "bytemuck", - "js-sys", - "log", - "raw-window-handle", - "web-sys", -] - -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix 0.38.44", - "winsafe", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" -dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-capture" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" -dependencies = [ - "parking_lot", - "rayon", - "thiserror 2.0.18", - "windows 0.61.3", - "windows-future 0.2.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", -] - -[[package]] -name = "windows-implement" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - -[[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.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "wio" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" -dependencies = [ - "winapi", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "workspace" -version = "0.4.9" -dependencies = [ - "pane_tree", -] - -[[package]] -name = "workspace-hack" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "rustix 1.1.4", - "x11rb-protocol", - "xcursor", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xattr" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" -dependencies = [ - "libc", -] - -[[package]] -name = "xcb" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" -dependencies = [ - "bitflags 1.3.2", - "libc", - "quick-xml 0.30.0", - "x11", -] - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - -[[package]] -name = "xim-ctext" -version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "xim-parser" -version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "xkbcommon" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" -dependencies = [ - "as-raw-xcb-connection", - "libc", - "memmap2", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xml5ever" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" -dependencies = [ - "log", - "mac", - "markup5ever", -] - -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yazi" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener 5.4.1", - "futures-core", - "futures-lite 2.6.1", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.2", - "zvariant", -] - -[[package]] -name = "zed-font-kit" -version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" -dependencies = [ - "bitflags 2.11.1", - "byteorder", - "core-foundation 0.10.0", - "core-graphics 0.24.0", - "core-text", - "dirs 6.0.0", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - -[[package]] -name = "zed-scap" -version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" -dependencies = [ - "anyhow", - "cocoa 0.25.0", - "core-graphics-helmer-fork", - "log", - "objc", - "rand 0.8.6", - "screencapturekit", - "screencapturekit-sys", - "sysinfo", - "tao-core-video-sys", - "windows 0.61.3", - "windows-capture", - "x11", - "xcb", -] - -[[package]] -name = "zed-sum-tree" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" -dependencies = [ - "arrayvec", - "log", - "rayon", - "workspace-hack", -] - -[[package]] -name = "zed-xim" -version = "0.4.0-zed" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "ahash", - "hashbrown 0.14.5", - "log", - "x11rb", - "xim-ctext", - "xim-parser", -] - -[[package]] -name = "zeno" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror 2.0.18", - "zopfli", -] - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - -[[package]] -name = "zlob" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef954ded52eaedecffc3fd98258716bfceb17a3f0fe6c509824c71fc6470dc3" -dependencies = [ - "bindgen 0.72.1", - "bitflags 2.11.1", -] - -[[package]] -name = "zlog" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "anyhow", - "chrono", - "collections", - "log", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zoom" -version = "0.4.9" -dependencies = [ - "gpui", - "settings", -] - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" -dependencies = [ - "tracing", - "tracing-subscriber", - "zlog", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#33eb2d83ed1a9f9ac7ca533178fa866cd4416edc" - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core 0.5.1", -] - -[[package]] -name = "zvariant" -version = "5.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" -dependencies = [ - "endi", - "enumflags2", - "serde", - "serde_bytes", - "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 1.0.2", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 4e54683a..00000000 --- a/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[workspace] -members = ["crates/*"] -exclude = ["vendor/gpui-component"] -resolver = "3" - -[workspace.package] -version = "0.4.9" -edition = "2024" -license = "MIT" - -[workspace.dependencies] -gix = "0.82.0" -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] } -gpui-component = { path = "vendor/gpui-component/crates/ui", features = ["tree-sitter-languages"] } -icondata_core = "0.1" -icondata_vs = "0.1" -rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/README.md b/README.md index 94a22fe3..2d1aa55f 100644 --- a/README.md +++ b/README.md @@ -19,154 +19,6 @@ Most editors dictate where things go. Kosmos lets you treat every view as a tab Kosmos only runs on Linux. Windows and macOS are not supported, and support for those platforms is not planned at this time. -## Installation - -Choose the install method that matches your Linux distribution or workflow. - -### Quick Install - -Use the install script for a distro-agnostic setup: - -```bash -curl -fsSL https://raw.githubusercontent.com/etchebarne/kosmos/main/scripts/install.sh | sh -``` - -The script: - -- Downloads the latest release tarball -- Installs Kosmos under `~/.local/Kosmos.app/` -- Symlinks `kosmos` into `~/.local/bin` -- Registers a desktop entry so Kosmos appears in your launcher - -### Manual Tarball Install - -Use this if you already downloaded the release bundle. - -Download `kosmos-linux-x86_64.tar.gz` from the [latest release](https://github.com/etchebarne/kosmos/releases/latest), then run: - -```bash -KOSMOS_BUNDLE_PATH=./kosmos-linux-x86_64.tar.gz \ - sh <(curl -fsSL https://raw.githubusercontent.com/etchebarne/kosmos/main/scripts/install.sh) -``` - -### Debian / Ubuntu - -Download `kosmos__amd64.deb` from the [latest release](https://github.com/etchebarne/kosmos/releases/latest), then: - -```bash -sudo apt install ./kosmos__amd64.deb -``` - -### Fedora / RPM Distros - -Download `kosmos--1.x86_64.rpm` from the [latest release](https://github.com/etchebarne/kosmos/releases/latest), then: - -```bash -sudo dnf install ./kosmos--1.x86_64.rpm -``` - -### AppImage - -Download `Kosmos--x86_64.AppImage` from the [latest release](https://github.com/etchebarne/kosmos/releases/latest), then: - -```bash -chmod +x ./Kosmos--x86_64.AppImage -./Kosmos--x86_64.AppImage -``` - -### Arch Linux (AUR) - -Install the pre-built binary: - -```bash -yay -S kosmos-bin -``` - -Or build from source: - -```bash -yay -S kosmos -``` - -## Uninstall - -Use the uninstall method that matches how you installed Kosmos. - -### Install Script - -```bash -curl -fsSL https://raw.githubusercontent.com/etchebarne/kosmos/main/scripts/uninstall.sh | sh -``` - -### Arch Linux (AUR) - -Remove the pre-built package: - -```bash -sudo pacman -R kosmos-bin -``` - -Or remove the source-built package: - -```bash -sudo pacman -R kosmos -``` - -### AppImage - -Remove the AppImage desktop entry and icon: - -```bash -./Kosmos--x86_64.AppImage --uninstall -``` - -Then delete the AppImage file itself. - -## Building from source - -Install [Rust](https://www.rust-lang.org/tools/install) stable and the GPUI system dependencies for your distro. - -### System Dependencies - -
-Arch Linux - -```bash -sudo pacman -S --needed base-devel pkgconf openssl fontconfig \ - libxcb libxkbcommon libxkbcommon-x11 wayland \ - vulkan-icd-loader vulkan-headers -``` - -
- -
-Debian / Ubuntu - -```bash -sudo apt install build-essential pkg-config libssl-dev libfontconfig-dev \ - libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libwayland-dev libvulkan-dev -``` - -
- -
-Fedora - -```bash -sudo dnf install gcc pkgconf openssl-devel fontconfig-devel \ - libxcb-devel libxkbcommon-devel libxkbcommon-x11-devel \ - wayland-devel vulkan-loader-devel -``` - -
- -### Run From Source - -```bash -cargo run --release -``` - ## License MIT - see [LICENSE](LICENSE). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md deleted file mode 100644 index badd1c44..00000000 --- a/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,84 +0,0 @@ -# Third-Party Notices - -This project bundles or depends on third-party software. Their licenses and -attributions are reproduced below. - ---- - -## icondata_vs - -Rust crate providing SVG data and metadata for the VS Code Codicons icon set. - -- Source: https://github.com/Carlosted/icondata -- License: MIT - -MIT License - -Copyright (c) Charles Edward Gagnon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## VS Code Codicons - -Icon set produced by Microsoft, redistributed via the `icondata_vs` crate. - -- Source: https://github.com/microsoft/vscode-codicons -- Code license: MIT -- Icon content license: Creative Commons Attribution 4.0 International - (https://creativecommons.org/licenses/by/4.0/legalcode) - -### MIT License (code) - -Copyright (c) Microsoft Corporation. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -### CC BY 4.0 (icon content) - -The icon content is licensed under the Creative Commons Attribution 4.0 -International Public License. The full license text is available at -https://creativecommons.org/licenses/by/4.0/legalcode. - ---- - -## gpui-component - -UI component library vendored and locally patched under `vendor/gpui-component`. - -- Source: https://github.com/longbridge/gpui-component -- License: Apache-2.0 -- License text: `vendor/gpui-component/LICENSE-APACHE` diff --git a/aur/kosmos-bin/PKGBUILD b/aur/kosmos-bin/PKGBUILD deleted file mode 100644 index 159bf5f7..00000000 --- a/aur/kosmos-bin/PKGBUILD +++ /dev/null @@ -1,46 +0,0 @@ -# Maintainer: Martin Etchebarne -pkgname=kosmos-bin -pkgver=0.4.9 -pkgrel=3 -pkgdesc="A modern code editor." -arch=('x86_64') -url="https://github.com/etchebarne/kosmos" -license=('MIT') -depends=( - 'fontconfig' - 'hicolor-icon-theme' - 'libxcb' - 'libxkbcommon' - 'libxkbcommon-x11' - 'vulkan-driver' - 'vulkan-icd-loader' - 'ttf-dejavu' - 'wayland' -) -provides=('kosmos') -conflicts=('kosmos') -source=("kosmos-linux-${arch}-${pkgver}.tar.gz::https://github.com/etchebarne/kosmos/releases/download/v${pkgver}/kosmos-linux-${arch}.tar.gz") -sha256sums=('SKIP') - -package() { - local appdir="$pkgdir/opt/kosmos" - install -d "$appdir" - cp -r "$srcdir/Kosmos.app/." "$appdir/" - - install -d "$pkgdir/usr/bin" - ln -s /opt/kosmos/bin/kosmos "$pkgdir/usr/bin/kosmos" - - install -Dm644 "$appdir/share/applications/net.etchebarne.Kosmos.desktop" \ - "$pkgdir/usr/share/applications/net.etchebarne.Kosmos.desktop" - - for size in 16 32 48 64 128 256 512; do - install -Dm644 "$appdir/share/icons/hicolor/${size}x${size}/apps/kosmos.png" \ - "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/kosmos.png" - done - install -Dm644 "$appdir/share/icons/hicolor/scalable/apps/kosmos.svg" \ - "$pkgdir/usr/share/icons/hicolor/scalable/apps/kosmos.svg" - - install -Dm644 "$appdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" - - rm -rf "$appdir/share/applications" "$appdir/share/icons" "$appdir/LICENSE" -} diff --git a/aur/kosmos/PKGBUILD b/aur/kosmos/PKGBUILD deleted file mode 100644 index c3c3dabc..00000000 --- a/aur/kosmos/PKGBUILD +++ /dev/null @@ -1,52 +0,0 @@ -# Maintainer: Martin Etchebarne -pkgname=kosmos -pkgver=0.4.9 -pkgrel=3 -pkgdesc="A modern code editor." -arch=('x86_64') -url="https://github.com/etchebarne/kosmos" -license=('MIT') -depends=( - 'fontconfig' - 'hicolor-icon-theme' - 'libxcb' - 'libxkbcommon' - 'libxkbcommon-x11' - 'vulkan-driver' - 'vulkan-icd-loader' - 'ttf-dejavu' - 'wayland' - 'zlib' -) -makedepends=('cargo' 'clang' 'pkgconf' 'rust' 'vulkan-headers' 'zig') -provides=('kosmos') -conflicts=('kosmos-bin') -source=("${pkgname}-${pkgver}.tar.gz::https://github.com/etchebarne/kosmos/archive/refs/tags/v${pkgver}.tar.gz") -sha256sums=('SKIP') - -build() { - cd "${pkgname}-${pkgver}" - cargo build --release --locked -p kosmos -} - -package() { - cd "${pkgname}-${pkgver}" - - install -Dm755 "target/release/kosmos" "$pkgdir/usr/bin/kosmos" - - install -d "$pkgdir/usr/share/applications" - APP_NAME="Kosmos" APP_CLI="kosmos" APP_ICON="kosmos" APP_ARGS="%U" \ - APP_ID="net.etchebarne.Kosmos" DO_STARTUP_NOTIFY="true" \ - envsubst < "packaging/linux/Kosmos.desktop.in" \ - > "$pkgdir/usr/share/applications/net.etchebarne.Kosmos.desktop" - chmod 644 "$pkgdir/usr/share/applications/net.etchebarne.Kosmos.desktop" - - for size in 16 32 48 64 128 256 512; do - install -Dm644 "assets/icon/icon-${size}.png" \ - "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/kosmos.png" - done - install -Dm644 "assets/icon/icon.svg" \ - "$pkgdir/usr/share/icons/hicolor/scalable/apps/kosmos.svg" - - install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" -} diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml deleted file mode 100644 index eb76204c..00000000 --- a/crates/ai/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "ai" -version = "0.4.9" -edition = "2024" - -[dependencies] diff --git a/crates/ai/README.md b/crates/ai/README.md deleted file mode 100644 index 0c2c9141..00000000 --- a/crates/ai/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ai - -Role: AI feature domain for Kosmos. - -Owns: -- Future AI provider/client abstractions. -- Future AI request, response, and session models. -- Future editor-agnostic AI workflows. - -Does Not Own: -- UI for AI interactions. -- Application orchestration. -- Editor buffer state. - -Status: placeholder crate; current code is a scaffold only. diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs deleted file mode 100644 index b93cf3ff..00000000 --- a/crates/ai/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} diff --git a/crates/app_shell/Cargo.toml b/crates/app_shell/Cargo.toml deleted file mode 100644 index 5cd5cc92..00000000 --- a/crates/app_shell/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "app_shell" -version = "0.4.9" -edition = "2024" - -[dependencies] -file_editor = { path = "../file_editor" } -file_tree = { path = "../file_tree" } -gpui = { workspace = true } -gpui_platform = { workspace = true } -gpui-component = { workspace = true } -icons = { path = "../icons" } -installer = { path = "../installer" } -pane_tree = { path = "../pane_tree" } -persistence = { path = "../persistence" } -registry = { path = "../registry" } -settings = { path = "../settings" } -shortcuts = { path = "../shortcuts" } -tabs = { path = "../tabs" } -terminal = { path = "../terminal" } -theme = { path = "../theme" } -ui = { path = "../ui" } -workspace = { path = "../workspace" } -zoom = { path = "../zoom" } diff --git a/crates/app_shell/README.md b/crates/app_shell/README.md deleted file mode 100644 index cb50ee2c..00000000 --- a/crates/app_shell/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# app_shell - -Role: application composition layer. - -Owns: -- GPUI application startup after the binary hands off control. -- Global state installation and feature-store bootstrapping. -- Main window creation and window persistence hooks. -- `KosmosApp`, delegate implementations, and cross-feature orchestration. -- Runtime coordination between workspace, pane, file tree, editor, settings, and persistence crates. - -Does Not Own: -- Domain models for panes, tabs, workspaces, settings, or files. -- Reusable GPUI components or tab rendering. -- Low-level persistence schemas or file-system operations. - -Use this crate when a feature needs to connect multiple crates together. diff --git a/crates/app_shell/src/app.rs b/crates/app_shell/src/app.rs deleted file mode 100644 index 8af01ea1..00000000 --- a/crates/app_shell/src/app.rs +++ /dev/null @@ -1,548 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use gpui::{ - App, AppContext, Context, Entity, FocusHandle, IntoElement, Render, Task, Window, div, - prelude::*, -}; - -use file_editor::BufferStore; -use file_tree::{FileTree, FileTreeEvent, FileTreeState}; -use gpui::BorrowAppContext; -use gpui_component::input as component_input; -use pane_tree::{PaneNode, PaneTree}; -use theme::ActiveTheme; -use ui::delegate::{HeaderMenuAction, HeaderMenuAvailability, SettingsUiState, TabScrollHandles}; -use ui::layout; -use ui::pane_tree_actions::WirePaneTreeActions; -use ui::tabs::file_search::FileSearchUi; -use ui::tabs::settings::SettingsInputs; -use workspace::WorkspaceManager; -use zoom::WireZoomActions; - -const TERMINAL_CWD_PERSIST_INTERVAL: Duration = Duration::from_millis(500); - -pub(crate) struct KosmosApp { - pub(crate) workspaces: WorkspaceManager, - pub(crate) tab_scrolls: TabScrollHandles, - pub(crate) file_tree: Entity, - focus_handle: FocusHandle, - workspace_watch_task: Option>, - terminal_cwd_persist_task: Option>, - /// Set whenever a transient mutation (e.g. a pane resize drag-move) has - /// changed workspace state without writing to disk. Drained by the next - /// non-transient mutation or on app quit, so the user's last resize ratio - /// always lands in the database eventually without thrashing the disk. - pending_persist: bool, -} - -impl KosmosApp { - pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { - ui::tabs::file_tree::FileTreeUi::install(window, cx); - FileSearchUi::install(window, cx); - SettingsInputs::install(window, cx); - let workspaces = persistence::load(); - let file_tree = Self::create_file_tree(cx); - let mut app = Self { - workspaces, - tab_scrolls: TabScrollHandles::new(), - file_tree, - focus_handle: cx.focus_handle(), - workspace_watch_task: None, - terminal_cwd_persist_task: None, - pending_persist: false, - }; - app.sync_file_tree_root(cx); - app.start_workspace_watch_task(cx); - app.start_terminal_cwd_persist_task(cx); - app.flush_pending_persist_on_quit(cx); - app - } - - fn create_file_tree(cx: &mut Context) -> Entity { - let file_tree = cx.new(FileTree::new); - cx.observe(&file_tree, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&file_tree, |this, _, event, cx| match event { - FileTreeEvent::FsChanged { paths } => BufferStore::reload_paths(paths.clone(), cx), - FileTreeEvent::ExpandedChanged { root, paths } => { - this.persist_file_tree_expanded_dirs(root.as_deref(), paths.clone(), cx); - } - }) - .detach(); - cx.set_global(FileTreeState::new()); - cx.update_global::(|state, _| { - state.set_active(Some(file_tree.clone())); - }); - file_tree - } - - fn flush_pending_persist_on_quit(&mut self, cx: &mut Context) { - cx.on_app_quit(|this, cx| { - // Flush workspace state on quit so live terminal cwd changes land - // on disk even if no pane-tree mutation happened after `cd`. - this.persist_all_workspaces(cx); - async {} - }) - .detach(); - } - - /// Periodically verify that every open workspace's backing directory - /// still exists. If a directory is removed externally, auto-close the - /// workspace so it doesn't dangle in the UI. - fn start_workspace_watch_task(&mut self, cx: &mut Context) { - let task = cx.spawn(async move |this, cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - - let Ok(paths) = this.update(cx, |this, _| { - this.workspaces - .workspaces() - .iter() - .map(|w| (w.id, w.path.clone())) - .collect::>() - }) else { - break; - }; - - if paths.is_empty() { - continue; - } - - let missing = cx - .background_executor() - .spawn(async move { - paths - .into_iter() - .filter(|(_, path)| matches!(path.try_exists(), Ok(false))) - .map(|(id, _)| id) - .collect::>() - }) - .await; - - if missing.is_empty() { - continue; - } - - if this - .update(cx, |this, cx| { - let mut closed = false; - for id in missing { - if this.workspaces.close(id) { - terminal::TerminalStore::drop_workspace(id, cx); - closed = true; - } - } - if closed { - this.sync_file_tree_root(cx); - cx.notify(); - persistence::save_session(&this.workspaces); - } - }) - .is_err() - { - break; - } - } - }); - self.workspace_watch_task = Some(task); - } - - fn start_terminal_cwd_persist_task(&mut self, cx: &mut Context) { - let task = cx.spawn(async move |this, cx| { - loop { - cx.background_executor() - .timer(TERMINAL_CWD_PERSIST_INTERVAL) - .await; - if this - .update(cx, |this, cx| this.persist_changed_terminal_directories(cx)) - .is_err() - { - break; - } - } - }); - self.terminal_cwd_persist_task = Some(task); - } - - pub(crate) fn sync_file_tree_root(&mut self, cx: &mut Context) { - let active = self - .workspaces - .active_workspace() - .map(|w| (w.path.clone(), w.file_tree_expanded_dirs.clone())); - if let Some((path, expanded)) = active { - self.file_tree.update(cx, |tree, cx| { - tree.set_root(path, expanded, cx); - }); - } - } - - pub(crate) fn sync_active_file_tree_expanded_dirs(&mut self, cx: &App) -> bool { - let Some(workspace_id) = self.workspaces.active_id() else { - return false; - }; - self.sync_file_tree_expanded_dirs_for_workspace(workspace_id, cx) - } - - fn sync_file_tree_expanded_dirs_for_workspace( - &mut self, - workspace_id: usize, - cx: &App, - ) -> bool { - let (root, paths) = { - let tree = self.file_tree.read(cx); - (tree.root().map(Path::to_path_buf), tree.expanded_paths()) - }; - self.set_file_tree_expanded_dirs_for_workspace(workspace_id, root.as_deref(), paths) - } - - fn persist_file_tree_expanded_dirs( - &mut self, - root: Option<&Path>, - paths: Vec, - cx: &mut Context, - ) { - let Some(workspace_id) = self.workspaces.active_id() else { - return; - }; - if !self.set_file_tree_expanded_dirs_for_workspace(workspace_id, root, paths) - && !self.pending_persist - { - return; - } - self.sync_terminal_paths_for_workspace(workspace_id, cx); - if let Some(workspace) = self.workspaces.workspace(workspace_id) { - persistence::save_workspace(workspace); - } - self.pending_persist = false; - } - - fn set_file_tree_expanded_dirs_for_workspace( - &mut self, - workspace_id: usize, - root: Option<&Path>, - mut paths: Vec, - ) -> bool { - let Some(workspace_path) = self - .workspaces - .workspace(workspace_id) - .map(|w| w.path.clone()) - else { - return false; - }; - if root != Some(workspace_path.as_path()) { - return false; - } - paths.retain(|path| path.starts_with(&workspace_path)); - paths.sort(); - paths.dedup(); - - let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else { - return false; - }; - if workspace.file_tree_expanded_dirs == paths { - return false; - } - workspace.file_tree_expanded_dirs = paths; - true - } - - pub(crate) fn start_observing_window(&mut self, window: &mut Window, cx: &mut Context) { - self.focus_handle.focus(window, cx); - cx.observe_window_bounds(window, |_, window, _| { - persistence::save_window_bounds(window.window_bounds()); - }) - .detach(); - } - - pub(crate) fn persist_active_workspace(&mut self, cx: &mut App) { - if let Some(workspace_id) = self.workspaces.active_id() { - self.persist_workspace(workspace_id, cx); - } else { - self.pending_persist = false; - } - } - - pub(crate) fn persist_workspace(&mut self, workspace_id: usize, cx: &mut App) { - self.sync_file_tree_expanded_dirs_for_workspace(workspace_id, cx); - self.sync_terminal_paths_for_workspace(workspace_id, cx); - if let Some(workspace) = self.workspaces.workspace(workspace_id) { - persistence::save_workspace(workspace); - } - if self.workspaces.active_id() == Some(workspace_id) { - self.pending_persist = false; - } - } - - pub(crate) fn persist_all_workspaces(&mut self, cx: &mut App) { - let workspace_ids = self - .workspaces - .workspaces() - .iter() - .map(|workspace| workspace.id) - .collect::>(); - for workspace_id in workspace_ids { - self.persist_workspace(workspace_id, cx); - } - persistence::save_session(&self.workspaces); - self.pending_persist = false; - } - - fn persist_changed_terminal_directories(&mut self, cx: &mut App) { - let workspace_ids = self - .workspaces - .workspaces() - .iter() - .map(|workspace| workspace.id) - .collect::>(); - for workspace_id in workspace_ids { - if !self.sync_terminal_paths_for_workspace(workspace_id, cx) { - continue; - } - if let Some(workspace) = self.workspaces.workspace(workspace_id) { - persistence::save_workspace(workspace); - } - if self.workspaces.active_id() == Some(workspace_id) { - self.pending_persist = false; - } - } - } - - fn sync_terminal_paths_for_workspace(&mut self, workspace_id: usize, cx: &mut App) -> bool { - let tab_ids = self - .workspaces - .pane_tree(workspace_id) - .map(|tree| terminal_tab_ids(tree.root())) - .unwrap_or_default(); - let updates = tab_ids - .into_iter() - .filter_map(|tab_id| { - let key = terminal::TerminalKey::new(workspace_id, tab_id); - let cwd = terminal::TerminalStore::cwd(key, cx)?; - (cwd.is_absolute() && cwd.is_dir()).then_some((tab_id, cwd)) - }) - .collect::>(); - if updates.is_empty() { - return false; - } - let mut changed = false; - if let Some(tree) = self.workspaces.pane_tree_mut(workspace_id) { - for (tab_id, cwd) in updates { - changed |= tree.set_tab_path(tab_id, Some(cwd)); - } - } - changed - } - - /// Write any deferred state to disk if a transient mutation left a - /// pending change behind. No-op when the cache is clean. - pub(crate) fn flush_pending_persist(&mut self, cx: &mut App) { - if self.pending_persist { - self.persist_active_workspace(cx); - } - } - - pub(crate) fn close_menu(&mut self, cx: &mut Context) { - let mut changed = false; - cx.update_global::(|state, _| { - if state.open_dropdown.take().is_some() { - changed = true; - } - }); - cx.update_global::(|state, _| { - if state.close_shell_picker() { - changed = true; - } - }); - if changed { - cx.notify(); - } - } - - pub(crate) fn mutate_active_tree( - &mut self, - cx: &mut Context, - f: impl FnOnce(&mut PaneTree) -> bool, - ) { - let Some(tree) = self.workspaces.active_pane_tree_mut() else { - return; - }; - if !f(tree) { - return; - } - cx.notify(); - self.persist_active_workspace(cx); - } - - /// Mutate the active pane tree without writing to disk. Use for high-rate - /// drag-driven operations like pane resize, where a per-frame SQLite - /// transaction (delete-and-rewrite of every pane node) is the dominant - /// cost. The mutation is marked dirty and gets flushed by the next - /// regular `mutate_active_tree` call or by `on_app_quit`. - pub(crate) fn mutate_active_tree_transient( - &mut self, - cx: &mut Context, - f: impl FnOnce(&mut PaneTree) -> bool, - ) { - let Some(tree) = self.workspaces.active_pane_tree_mut() else { - return; - }; - if !f(tree) { - return; - } - cx.notify(); - self.pending_persist = true; - } - - fn active_tab(&self) -> Option<&tabs::Tab> { - self.workspaces - .active_pane_tree() - .and_then(|tree| tree.active_pane()) - .and_then(|pane| pane.tabs().iter().find(|tab| tab.id == pane.active_tab())) - } - - fn active_editor_tab(&self) -> Option<&tabs::Tab> { - self.active_tab() - .filter(|tab| tab.kind.as_str() == tabs::registry::FILE_EDITOR.id) - .filter(|tab| tab.path.is_some()) - } - - fn active_editor_tab_parts(&self) -> Option<(usize, PathBuf)> { - let tab = self.active_editor_tab()?; - Some((tab.id, tab.path.clone()?)) - } - - pub(crate) fn header_menu_availability(&self, cx: &Context) -> HeaderMenuAvailability { - let mut availability = HeaderMenuAvailability { - any_dirty_file: BufferStore::has_dirty_buffers(cx), - ..Default::default() - }; - - let Some(tab) = self.active_editor_tab() else { - return availability; - }; - - availability.active_editor = true; - availability.can_undo = true; - availability.can_redo = true; - availability.can_cut = true; - availability.can_copy = true; - availability.can_paste = true; - availability.can_select_all = true; - - if let Some(path) = tab.path.as_deref() { - availability.active_editor_dirty = BufferStore::is_path_dirty(path, cx); - } - - availability - } - - pub(crate) fn save_active_editor(&mut self, cx: &mut Context) { - let Some((_, path)) = self.active_editor_tab_parts() else { - return; - }; - if let Err(err) = BufferStore::save_path(&path, cx) { - eprintln!("failed to save {}: {err}", path.display()); - } - cx.notify(); - } - - pub(crate) fn save_all_files(&mut self, cx: &mut Context) { - if let Err(err) = BufferStore::save_all(cx) { - eprintln!("failed to save all files: {err}"); - } - cx.notify(); - } - - pub(crate) fn run_header_editor_action( - &mut self, - action: HeaderMenuAction, - window: &mut Window, - cx: &mut Context, - ) { - match action { - HeaderMenuAction::Undo => window.dispatch_action(Box::new(component_input::Undo), cx), - HeaderMenuAction::Redo => window.dispatch_action(Box::new(component_input::Redo), cx), - HeaderMenuAction::Cut => window.dispatch_action(Box::new(component_input::Cut), cx), - HeaderMenuAction::Copy => window.dispatch_action(Box::new(component_input::Copy), cx), - HeaderMenuAction::Paste => window.dispatch_action(Box::new(component_input::Paste), cx), - HeaderMenuAction::SelectAll => { - window.dispatch_action(Box::new(component_input::SelectAll), cx) - } - HeaderMenuAction::OpenFolder - | HeaderMenuAction::Save - | HeaderMenuAction::SaveAll - | HeaderMenuAction::ExpandSelection - | HeaderMenuAction::ShrinkSelection => {} - } - cx.notify(); - } - - fn save_active_file(&mut self, _: &file_editor::Save, _: &mut Window, cx: &mut Context) { - self.save_active_editor(cx); - } -} - -impl Render for KosmosApp { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = *cx.theme(); - let header_menu_availability = self.header_menu_availability(cx); - zoom::apply(window, cx); - div() - .id("app-root") - .track_focus(&self.focus_handle) - .key_context(shortcuts::CONTEXT) - .wire_pane_tree_actions(cx) - .wire_zoom_actions(cx) - .on_action(cx.listener(Self::save_active_file)) - .relative() - .size_full() - .flex() - .flex_col() - .gap_1() - .p_1() - .bg(theme.bg_root) - .on_click(cx.listener(|this, _, _, cx| this.close_menu(cx))) - .child(layout::header::render( - &self.workspaces, - header_menu_availability, - window, - cx, - )) - .child(div().flex_1().min_h_0().child(layout::main_content::render( - &self.workspaces, - &self.tab_scrolls, - window, - cx, - ))) - .child(ui::tabs::git::render_modal_overlay(window, cx)) - .child(render_component_layers(window, cx)) - } -} - -fn render_component_layers(window: &mut Window, cx: &mut App) -> impl IntoElement { - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .children(gpui_component::Root::render_sheet_layer(window, cx)) - .children(gpui_component::Root::render_dialog_layer(window, cx)) - .children(gpui_component::Root::render_notification_layer(window, cx)) -} - -fn terminal_tab_ids(node: &PaneNode) -> Vec { - match node { - PaneNode::Leaf(pane) => pane - .tabs() - .iter() - .filter(|tab| tab.kind.as_str() == tabs::registry::TERMINAL.id) - .map(|tab| tab.id) - .collect(), - PaneNode::Split { first, second, .. } => { - let mut ids = terminal_tab_ids(first); - ids.extend(terminal_tab_ids(second)); - ids - } - } -} diff --git a/crates/app_shell/src/delegates/header.rs b/crates/app_shell/src/delegates/header.rs deleted file mode 100644 index f3ff04f6..00000000 --- a/crates/app_shell/src/delegates/header.rs +++ /dev/null @@ -1,27 +0,0 @@ -use gpui::{Context, Window}; -use ui::delegate::{HeaderDelegate, HeaderMenuAction, WorkspaceDelegate}; - -use crate::app::KosmosApp; - -impl HeaderDelegate for KosmosApp { - fn activate_header_menu_action( - &mut self, - action: HeaderMenuAction, - window: &mut Window, - cx: &mut Context, - ) { - match action { - HeaderMenuAction::OpenFolder => self.open_workspace_picker(cx), - HeaderMenuAction::Save => self.save_active_editor(cx), - HeaderMenuAction::SaveAll => self.save_all_files(cx), - HeaderMenuAction::Undo - | HeaderMenuAction::Redo - | HeaderMenuAction::Cut - | HeaderMenuAction::Copy - | HeaderMenuAction::Paste - | HeaderMenuAction::SelectAll => self.run_header_editor_action(action, window, cx), - HeaderMenuAction::ExpandSelection | HeaderMenuAction::ShrinkSelection => {} - } - cx.notify(); - } -} diff --git a/crates/app_shell/src/delegates/mod.rs b/crates/app_shell/src/delegates/mod.rs deleted file mode 100644 index 5befeb71..00000000 --- a/crates/app_shell/src/delegates/mod.rs +++ /dev/null @@ -1,222 +0,0 @@ -mod header; -mod pane; -mod pane_tree_actions; -mod settings; -mod workspace; - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use gpui::Context; -use pane_tree::PaneTree; -use tabs::Tab; -use ui::delegate::{TabAnimationState, TabScrollHandles}; -use ui::metrics::TAB_ANIMATION_DURATION_MS; - -use crate::app::KosmosApp; - -impl KosmosApp { - pub(crate) fn start_tab_open_animation( - &mut self, - pane_id: usize, - tab_id: usize, - cx: &mut Context, - ) { - let Some(workspace_id) = self.workspaces.active_id() else { - return; - }; - if !cx - .default_global::() - .start_opening(workspace_id, pane_id, tab_id) - { - return; - } - - cx.notify(); - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(TAB_ANIMATION_DURATION_MS)) - .await; - let _ = this.update(cx, move |_, cx| { - if cx.default_global::().finish_opening( - workspace_id, - pane_id, - tab_id, - ) { - cx.notify(); - } - }); - }) - .detach(); - } - - pub(crate) fn start_tab_close_animation( - &mut self, - pane_id: usize, - tab_id: usize, - cx: &mut Context, - ) { - let Some(workspace_id) = self.workspaces.active_id() else { - return; - }; - let can_close = self.workspaces.active_pane_tree().is_some_and(|tree| { - tree.total_tabs() > 1 && tree.pane(pane_id).is_some_and(|pane| pane.has_tab(tab_id)) - }); - if !can_close { - return; - } - let terminal_key = terminal_tab_key(&self.workspaces, pane_id, tab_id); - let is_editor_tab = self - .workspaces - .active_pane_tree() - .is_some_and(|tree| is_file_editor_tree_tab(tree, pane_id, tab_id)); - - if !cx - .default_global::() - .start_closing(workspace_id, pane_id, tab_id) - { - return; - } - - cx.notify(); - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(TAB_ANIMATION_DURATION_MS)) - .await; - let _ = this.update(cx, move |this, cx| { - let should_close = cx.default_global::().finish_closing( - workspace_id, - pane_id, - tab_id, - ); - if should_close - && !this.finish_tab_close( - workspace_id, - pane_id, - tab_id, - terminal_key, - is_editor_tab, - cx, - ) - { - cx.notify(); - } - }); - }) - .detach(); - } - - fn finish_tab_close( - &mut self, - workspace_id: usize, - pane_id: usize, - tab_id: usize, - terminal_key: Option, - is_editor_tab: bool, - cx: &mut Context, - ) -> bool { - let mut closed = false; - if let Some(tree) = self.workspaces.pane_tree_mut(workspace_id) { - closed = tree.close_tab(pane_id, tab_id); - } - if closed { - cx.notify(); - self.persist_workspace(workspace_id, cx); - if is_editor_tab { - ui::tabs::drop_file_editor_tab(tab_id, cx); - } - if let Some(key) = terminal_key { - terminal::TerminalStore::drop_tab(key, cx); - } - } - closed - } -} - -fn scroll_tabs_to_end(tab_scrolls: &TabScrollHandles, pane_id: usize, tab_count: usize) -> bool { - if tab_count == 0 { - return false; - } - // The scrollable strip is: tab, divider, tab, ..., tab. - // `n` tabs + `n - 1` dividers = `2 * n - 1` children. - let scroll_index = 2 * tab_count - 2; - tab_scrolls.scroll_to_index(pane_id, scroll_index); - true -} - -fn anchor_tabs_to_end_during_open_animation( - tab_scrolls: &TabScrollHandles, - pane_id: usize, - tab_count: usize, - cx: &mut Context, -) { - if !scroll_tabs_to_end(tab_scrolls, pane_id, tab_count) { - return; - } - - tab_scrolls.start_end_anchor(pane_id); - cx.notify(); - - let tab_scrolls = tab_scrolls.clone(); - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(TAB_ANIMATION_DURATION_MS)) - .await; - let _ = this.update(cx, move |_, cx| { - tab_scrolls.finish_end_anchor(pane_id); - cx.notify(); - }); - }) - .detach(); -} - -fn terminal_tab_key( - workspaces: &::workspace::WorkspaceManager, - pane_id: usize, - tab_id: usize, -) -> Option { - let workspace_id = workspaces.active_id()?; - let tree = workspaces.active_pane_tree()?; - let tab = tree - .pane(pane_id)? - .tabs() - .iter() - .find(|tab| tab.id == tab_id)?; - (tab.kind.as_str() == tabs::registry::TERMINAL.id) - .then_some(terminal::TerminalKey::new(workspace_id, tab_id)) -} - -fn is_file_editor_tree_tab(tree: &PaneTree, pane_id: usize, tab_id: usize) -> bool { - tree.pane(pane_id) - .and_then(|pane| pane.tabs().iter().find(|tab| tab.id == tab_id)) - .is_some_and(|tab| tab.kind.as_str() == tabs::registry::FILE_EDITOR.id) -} - -fn file_editor_tab(tab_id: usize, path: PathBuf) -> Tab { - let title = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - Tab::new(tab_id, &tabs::registry::FILE_EDITOR) - .with_title(title) - .with_path(path) -} - -fn diff_tab(tab_id: usize, root: PathBuf) -> Tab { - Tab::new(tab_id, &tabs::registry::DIFF) - .with_title("Diff") - .with_path(root) -} - -fn terminal_tab(tab_id: usize, cwd: PathBuf) -> Tab { - Tab::new(tab_id, &tabs::registry::TERMINAL).with_path(cwd) -} - -fn is_file_editor_tab(tab: &Tab, path: &Path) -> bool { - tab.kind.as_str() == tabs::registry::FILE_EDITOR.id && tab.path.as_deref() == Some(path) -} - -fn tab_count(tree: &PaneTree, pane_id: usize) -> usize { - tree.pane(pane_id).map(|p| p.tabs().len()).unwrap_or(0) -} diff --git a/crates/app_shell/src/delegates/pane.rs b/crates/app_shell/src/delegates/pane.rs deleted file mode 100644 index c4f2330c..00000000 --- a/crates/app_shell/src/delegates/pane.rs +++ /dev/null @@ -1,309 +0,0 @@ -use std::path::PathBuf; - -use gpui::Context; -use pane_tree::DropZone; -use ui::delegate::PaneDelegate; -use ui::drag::TabDrag; - -use crate::app::KosmosApp; - -use super::{ - anchor_tabs_to_end_during_open_animation, diff_tab, file_editor_tab, is_file_editor_tab, - scroll_tabs_to_end, tab_count, terminal_tab, terminal_tab_key, -}; - -impl PaneDelegate for KosmosApp { - fn focus_pane(&mut self, pane_id: usize, _cx: &mut Context) { - if let Some(tree) = self.workspaces.active_pane_tree_mut() { - tree.focus_pane(pane_id); - } - } - - fn add_tab(&mut self, pane_id: usize, kind_id: &'static str, cx: &mut Context) { - let Some(kind) = tabs::registry::get(kind_id) else { - return; - }; - let mut new_count: Option = None; - let mut new_tab_id: Option = None; - let terminal_cwd = self.workspaces.active_workspace().map(|w| w.path.clone()); - self.mutate_active_tree(cx, |tree| { - let tab_id = tree.next_tab_id(); - let added = if kind_id == tabs::registry::TERMINAL.id { - let Some(cwd) = terminal_cwd.clone() else { - return false; - }; - tree.append_new_tab(pane_id, |id| terminal_tab(id, cwd)) - .is_some() - } else { - tree.add_tab(pane_id, kind) - }; - if !added { - return false; - } - new_tab_id = Some(tab_id); - new_count = tree.active_pane().map(|p| p.tabs().len()); - true - }); - if let Some(tab_id) = new_tab_id { - self.start_tab_open_animation(pane_id, tab_id, cx); - } - if let Some(count) = new_count { - anchor_tabs_to_end_during_open_animation(&self.tab_scrolls, pane_id, count, cx); - } - } - - fn replace_tab_kind( - &mut self, - pane_id: usize, - tab_id: usize, - kind_id: &'static str, - cx: &mut Context, - ) { - let Some(kind) = tabs::registry::get(kind_id) else { - return; - }; - let old_terminal_key = (kind_id != tabs::registry::TERMINAL.id) - .then(|| terminal_tab_key(&self.workspaces, pane_id, tab_id)) - .flatten(); - let terminal_cwd = self.workspaces.active_workspace().map(|w| w.path.clone()); - self.mutate_active_tree(cx, |tree| { - if !tree.replace_tab_kind(pane_id, tab_id, kind) { - return false; - } - if kind_id == tabs::registry::TERMINAL.id - && let Some(cwd) = terminal_cwd.clone() - { - tree.set_tab_path(tab_id, Some(cwd)); - } - true - }); - if let Some(key) = old_terminal_key { - terminal::TerminalStore::drop_tab(key, cx); - } - } - - fn select_tab(&mut self, pane_id: usize, tab_id: usize, cx: &mut Context) { - self.mutate_active_tree(cx, |tree| tree.select_tab(pane_id, tab_id)); - } - - fn close_tab(&mut self, pane_id: usize, tab_id: usize, cx: &mut Context) { - self.start_tab_close_animation(pane_id, tab_id, cx); - } - - fn move_tab_before( - &mut self, - drag: TabDrag, - target_pane_id: usize, - target_tab_id: usize, - cx: &mut Context, - ) { - self.mutate_active_tree(cx, |tree| { - tree.move_tab_before(drag.source_pane_id, drag.id, target_pane_id, target_tab_id) - }); - } - - fn move_tab_to_pane(&mut self, drag: TabDrag, target_pane_id: usize, cx: &mut Context) { - self.mutate_active_tree(cx, |tree| { - tree.move_tab_to_pane(drag.source_pane_id, drag.id, target_pane_id) - }); - } - - fn move_tab_to_end(&mut self, drag: TabDrag, target_pane_id: usize, cx: &mut Context) { - self.mutate_active_tree(cx, |tree| { - tree.move_tab_to_end(drag.source_pane_id, drag.id, target_pane_id) - }); - } - - fn split_pane( - &mut self, - drag: TabDrag, - target_pane_id: usize, - drop_zone: DropZone, - cx: &mut Context, - ) { - self.mutate_active_tree(cx, |tree| { - tree.split_pane(drag.source_pane_id, drag.id, target_pane_id, drop_zone) - }); - } - - fn resize_split(&mut self, split_id: usize, ratio: f32, cx: &mut Context) { - self.mutate_active_tree_transient(cx, |tree| tree.resize_split(split_id, ratio)); - } - - fn finish_resize_split(&mut self, cx: &mut Context) { - self.flush_pending_persist(cx); - } - - fn open_file(&mut self, path: PathBuf, cx: &mut Context) { - let mut opened: Option<(usize, usize)> = None; - let mut new_tab: Option<(usize, usize)> = None; - self.mutate_active_tree(cx, |tree| { - if let Some((pane_id, tab_id)) = tree.find_tab(|tab| is_file_editor_tab(tab, &path)) { - if !tree.select_tab(pane_id, tab_id) { - return false; - } - opened = Some((pane_id, tab_count(tree, pane_id))); - return true; - } - - let pane_id = tree.biggest_pane_id(); - let tab_id = tree.next_tab_id(); - let path = path.clone(); - opened = tree.append_new_tab(pane_id, |id| file_editor_tab(id, path)); - if opened.is_some() { - new_tab = Some((pane_id, tab_id)); - } - opened.is_some() - }); - if let Some((pane_id, tab_id)) = new_tab { - self.start_tab_open_animation(pane_id, tab_id, cx); - if let Some((_, count)) = opened { - anchor_tabs_to_end_during_open_animation(&self.tab_scrolls, pane_id, count, cx); - } - } else if let Some((pane_id, count)) = opened { - scroll_tabs_to_end(&self.tab_scrolls, pane_id, count); - } - } - - fn open_file_in_pane(&mut self, path: PathBuf, target_pane_id: usize, cx: &mut Context) { - let mut opened: Option<(usize, usize)> = None; - let mut new_tab: Option<(usize, usize)> = None; - self.mutate_active_tree(cx, |tree| { - let existing = tree.pane(target_pane_id).and_then(|pane| { - pane.tabs() - .iter() - .find(|tab| is_file_editor_tab(tab, &path)) - .map(|tab| tab.id) - }); - if let Some(tab_id) = existing { - if !tree.select_tab(target_pane_id, tab_id) { - return false; - } - opened = Some((target_pane_id, tab_count(tree, target_pane_id))); - return true; - } - - let tab_id = tree.next_tab_id(); - let path = path.clone(); - opened = tree.append_new_tab(target_pane_id, |id| file_editor_tab(id, path)); - if opened.is_some() { - new_tab = Some((target_pane_id, tab_id)); - } - opened.is_some() - }); - if let Some((pane_id, tab_id)) = new_tab { - self.start_tab_open_animation(pane_id, tab_id, cx); - if let Some((_, count)) = opened { - anchor_tabs_to_end_during_open_animation(&self.tab_scrolls, pane_id, count, cx); - } - } else if let Some((pane_id, count)) = opened { - scroll_tabs_to_end(&self.tab_scrolls, pane_id, count); - } - } - - fn open_file_before( - &mut self, - path: PathBuf, - target_pane_id: usize, - target_tab_id: usize, - cx: &mut Context, - ) { - let mut new_tab: Option<(usize, usize)> = None; - self.mutate_active_tree(cx, |tree| { - let Some(pane) = tree.pane(target_pane_id) else { - return false; - }; - if !pane.has_tab(target_tab_id) { - return false; - } - let existing = pane - .tabs() - .iter() - .find(|tab| is_file_editor_tab(tab, &path)) - .map(|tab| tab.id); - if let Some(tab_id) = existing { - return tree.select_tab(target_pane_id, tab_id); - } - - let tab_id = tree.next_tab_id(); - let path = path.clone(); - let opened = tree.insert_new_tab_before(target_pane_id, target_tab_id, |id| { - file_editor_tab(id, path) - }); - if opened.is_some() { - new_tab = Some((target_pane_id, tab_id)); - } - opened.is_some() - }); - if let Some((pane_id, tab_id)) = new_tab { - self.start_tab_open_animation(pane_id, tab_id, cx); - } - } - - fn open_file_at(&mut self, path: PathBuf, line: usize, column: usize, cx: &mut Context) { - ui::tabs::request_file_editor_reveal(path.clone(), line, column, cx); - self.open_file(path, cx); - } - - fn open_diff_for_file(&mut self, root: PathBuf, file_path: String, cx: &mut Context) { - ui::tabs::request_diff_focus(root.clone(), file_path, cx); - let mut opened: Option<(usize, usize)> = None; - let mut new_tab: Option<(usize, usize)> = None; - self.mutate_active_tree(cx, |tree| { - if let Some((pane_id, tab_id)) = tree.find_tab(|tab| { - tab.kind.as_str() == tabs::registry::DIFF.id - && tab.path.as_deref() == Some(root.as_path()) - }) { - if !tree.select_tab(pane_id, tab_id) { - return false; - } - opened = Some((pane_id, tab_count(tree, pane_id))); - return true; - } - - let pane_id = tree.biggest_pane_id(); - let tab_id = tree.next_tab_id(); - let root = root.clone(); - opened = tree.append_new_tab(pane_id, |id| diff_tab(id, root)); - if opened.is_some() { - new_tab = Some((pane_id, tab_id)); - } - opened.is_some() - }); - if let Some((pane_id, tab_id)) = new_tab { - self.start_tab_open_animation(pane_id, tab_id, cx); - if let Some((_, count)) = opened { - anchor_tabs_to_end_during_open_animation(&self.tab_scrolls, pane_id, count, cx); - } - } else if let Some((pane_id, count)) = opened { - scroll_tabs_to_end(&self.tab_scrolls, pane_id, count); - } - } - - fn split_pane_with_file( - &mut self, - path: PathBuf, - target_pane_id: usize, - drop_zone: DropZone, - cx: &mut Context, - ) { - let mut opened: Option<(usize, usize)> = None; - let mut new_tab: Option<(usize, usize)> = None; - self.mutate_active_tree(cx, |tree| { - let tab_id = tree.next_tab_id(); - let path = path.clone(); - opened = tree - .split_pane_with_new_tab(target_pane_id, drop_zone, |id| file_editor_tab(id, path)); - if let Some((pane_id, _)) = opened { - new_tab = Some((pane_id, tab_id)); - } - opened.is_some() - }); - if let Some((pane_id, tab_id)) = new_tab { - self.start_tab_open_animation(pane_id, tab_id, cx); - } - if let Some((pane_id, count)) = opened { - scroll_tabs_to_end(&self.tab_scrolls, pane_id, count); - } - } -} diff --git a/crates/app_shell/src/delegates/pane_tree_actions.rs b/crates/app_shell/src/delegates/pane_tree_actions.rs deleted file mode 100644 index 8703e80f..00000000 --- a/crates/app_shell/src/delegates/pane_tree_actions.rs +++ /dev/null @@ -1,35 +0,0 @@ -use gpui::Context; -use pane_tree::PaneTree; -use ui::pane_tree_actions::PaneTreeActionDelegate; - -use crate::app::KosmosApp; - -use super::anchor_tabs_to_end_during_open_animation; - -impl PaneTreeActionDelegate for KosmosApp { - fn with_active_tree(&mut self, cx: &mut Context, f: impl FnOnce(&mut PaneTree) -> bool) { - self.mutate_active_tree(cx, f); - } - - fn close_active_tab(&mut self, cx: &mut Context) { - let tab = self.workspaces.active_pane_tree().and_then(|tree| { - let pane_id = tree.active_pane_id(); - let tab_id = tree.active_pane()?.active_tab(); - Some((pane_id, tab_id)) - }); - if let Some((pane_id, tab_id)) = tab { - self.start_tab_close_animation(pane_id, tab_id, cx); - } - } - - fn on_tab_appended( - &mut self, - pane_id: usize, - tab_id: usize, - new_tab_count: usize, - cx: &mut Context, - ) { - self.start_tab_open_animation(pane_id, tab_id, cx); - anchor_tabs_to_end_during_open_animation(&self.tab_scrolls, pane_id, new_tab_count, cx); - } -} diff --git a/crates/app_shell/src/delegates/settings.rs b/crates/app_shell/src/delegates/settings.rs deleted file mode 100644 index 35021d41..00000000 --- a/crates/app_shell/src/delegates/settings.rs +++ /dev/null @@ -1,102 +0,0 @@ -use gpui::{BorrowAppContext, Context}; -use settings::{SettingValue, Settings}; -use ui::delegate::{SettingsDelegate, SettingsUiState}; - -use crate::app::KosmosApp; - -impl SettingsDelegate for KosmosApp { - fn select_settings_category(&mut self, category_id: &'static str, cx: &mut Context) { - let mut changed = false; - cx.update_global::(|state, _| { - if state.active_category != category_id { - state.active_category = category_id; - state.open_dropdown = None; - changed = true; - } - }); - if changed { - cx.notify(); - } - } - - fn toggle_settings_dropdown(&mut self, setting_id: &'static str, cx: &mut Context) { - cx.update_global::(|state, _| { - state.open_dropdown = if state.open_dropdown == Some(setting_id) { - None - } else { - Some(setting_id) - }; - }); - cx.notify(); - } - - fn set_setting_value( - &mut self, - key: &'static str, - value: SettingValue, - cx: &mut Context, - ) { - let theme_id = (key == theme::SETTING_ID).then(|| { - value - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| theme::DEFAULT_ID.to_string()) - }); - cx.update_global::(|settings, _| { - settings.set(key, value); - }); - if let Some(theme_id) = theme_id { - theme::apply(&theme_id, cx); - } - cx.notify(); - } - - fn install_tool(&mut self, entry: &'static registry::RegistryEntry, cx: &mut Context) { - let tool_id = entry.id; - let already = cx.global::().installing.contains(tool_id); - if already { - return; - } - cx.update_global::(|state, _| { - state.installing.insert(tool_id); - state.install_errors.remove(tool_id); - }); - cx.notify(); - - cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { installer::ensure(entry) }) - .await; - let _ = this.update(cx, |_, cx| { - cx.update_global::(|state, _| { - state.installing.remove(tool_id); - if let Err(err) = &result { - state - .install_errors - .insert(tool_id, format!("{err}").into()); - } - }); - cx.notify(); - }); - }) - .detach(); - } - - fn uninstall_tool(&mut self, entry: &'static registry::RegistryEntry, cx: &mut Context) { - let dir = installer::tool_dir(entry); - let tool_id = entry.id; - let result = std::fs::remove_dir_all(&dir); - cx.update_global::(|state, _| { - state.install_errors.remove(tool_id); - if let Err(err) = result - && err.kind() != std::io::ErrorKind::NotFound - { - state - .install_errors - .insert(tool_id, format!("uninstall failed: {err}").into()); - } - }); - cx.notify(); - } -} diff --git a/crates/app_shell/src/delegates/workspace.rs b/crates/app_shell/src/delegates/workspace.rs deleted file mode 100644 index 269713f9..00000000 --- a/crates/app_shell/src/delegates/workspace.rs +++ /dev/null @@ -1,66 +0,0 @@ -use gpui::{Context, PathPromptOptions}; -use ui::delegate::WorkspaceDelegate; - -use crate::app::KosmosApp; - -impl WorkspaceDelegate for KosmosApp { - fn open_workspace_picker(&mut self, cx: &mut Context) { - let receiver = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Open Folder".into()), - }); - cx.spawn(async move |this, cx| { - let Ok(Ok(Some(paths))) = receiver.await else { - return; - }; - let Some(path) = paths.into_iter().next() else { - return; - }; - let _ = this.update(cx, |this, cx| { - this.sync_active_file_tree_expanded_dirs(cx); - this.workspaces.add(path); - this.sync_file_tree_root(cx); - cx.notify(); - this.persist_active_workspace(cx); - persistence::save_session(&this.workspaces); - }); - }) - .detach(); - } - - fn select_workspace(&mut self, id: usize, cx: &mut Context) { - self.sync_active_file_tree_expanded_dirs(cx); - if self.workspaces.select(id) { - self.sync_file_tree_root(cx); - cx.notify(); - persistence::save_session(&self.workspaces); - } - } - - fn move_workspace_before(&mut self, drag_id: usize, target_id: usize, cx: &mut Context) { - if self.workspaces.reorder_before(drag_id, target_id) { - cx.notify(); - persistence::save_session(&self.workspaces); - } - } - - fn move_workspace_to_end(&mut self, drag_id: usize, cx: &mut Context) { - if self.workspaces.move_to_end(drag_id) { - cx.notify(); - persistence::save_session(&self.workspaces); - } - } - - fn close_workspace(&mut self, id: usize, cx: &mut Context) { - self.sync_active_file_tree_expanded_dirs(cx); - if !self.workspaces.close(id) { - return; - } - terminal::TerminalStore::drop_workspace(id, cx); - self.sync_file_tree_root(cx); - cx.notify(); - persistence::save_session(&self.workspaces); - } -} diff --git a/crates/app_shell/src/lib.rs b/crates/app_shell/src/lib.rs deleted file mode 100644 index b6276afb..00000000 --- a/crates/app_shell/src/lib.rs +++ /dev/null @@ -1,90 +0,0 @@ -mod app; -mod delegates; - -use gpui::{App, AppContext, Bounds, WindowBounds, WindowDecorations, WindowOptions, px, size}; -use icons::AppAssets; -use settings::SettingValue; - -use crate::app::KosmosApp; - -const APP_NAME: &str = "Kosmos"; -const DEFAULT_APP_ID: &str = "net.etchebarne.Kosmos"; - -pub fn run() { - gpui_platform::application() - .with_assets(AppAssets) - .run(|cx: &mut App| { - install_globals(cx); - install_feature_state(cx); - install_keybindings(cx); - open_main_window(cx); - cx.activate(true); - }); -} - -fn install_globals(cx: &mut App) { - gpui_component::init(cx); - cx.set_global(settings::Settings::load()); - install_theme(cx); - cx.set_global(ui::delegate::SettingsUiState::new()); - cx.set_global(ui::delegate::TabAnimationState::default()); -} - -fn install_theme(cx: &mut App) { - theme::install(selected_theme_id(cx), cx); - cx.observe_global::(|cx| { - theme::apply(selected_theme_id(cx), cx); - }) - .detach(); -} - -fn selected_theme_id(cx: &App) -> &'static str { - cx.global::() - .get(theme::SETTING_ID) - .and_then(SettingValue::as_str) - .map(theme::Theme::normalize_id) - .unwrap_or(theme::DEFAULT_ID) -} - -fn install_feature_state(cx: &mut App) { - ui::tabs::terminal::TerminalUi::install(cx); - file_editor::BufferStore::install(cx); - terminal::TerminalStore::install(cx); -} - -fn install_keybindings(cx: &mut App) { - ui::tabs::install_keybindings(cx); - shortcuts::install_defaults(cx); -} - -fn open_main_window(cx: &mut App) { - let window_bounds = persistence::load_window_bounds().unwrap_or_else(|| { - let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx); - WindowBounds::Windowed(bounds) - }); - - cx.open_window( - WindowOptions { - window_bounds: Some(window_bounds), - titlebar: None, - window_decorations: Some(WindowDecorations::Client), - window_min_size: Some(size(px(800.0), px(600.0))), - app_id: Some(runtime_app_id()), - ..Default::default() - }, - |window, cx| { - window.set_window_title(APP_NAME); - let entity = cx.new(|cx| KosmosApp::new(window, cx)); - entity.update(cx, |app, cx| app.start_observing_window(window, cx)); - cx.new(|cx| gpui_component::Root::new(entity, window, cx)) - }, - ) - .unwrap(); -} - -fn runtime_app_id() -> String { - std::env::var("KOSMOS_APP_ID") - .ok() - .filter(|app_id| !app_id.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_APP_ID.to_string()) -} diff --git a/crates/file_editor/Cargo.toml b/crates/file_editor/Cargo.toml deleted file mode 100644 index faf7652b..00000000 --- a/crates/file_editor/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "file_editor" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -language = { path = "../language" } -settings = { path = "../settings" } diff --git a/crates/file_editor/README.md b/crates/file_editor/README.md deleted file mode 100644 index b5247f0f..00000000 --- a/crates/file_editor/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# file_editor - -Role: editor buffer and editor-view state. - -Owns: -- File buffer loading, saving, dirty tracking, and buffer identity. -- Text edit application, undo/redo state, selections, and cursor movement state. -- Per-tab editor view state such as scroll, folds, hover status, and cached layout data. -- Editor-specific actions such as save. - -Does Not Own: -- Pane or tab layout. -- File-editor tab rendering. -- Syntax parsing, theme styling, or LSP transport. -- Workspace/session persistence. diff --git a/crates/file_editor/src/lib.rs b/crates/file_editor/src/lib.rs deleted file mode 100644 index cf4ccd29..00000000 --- a/crates/file_editor/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod store; - -pub use store::BufferStore; - -include!("parts/model.rs"); -include!("parts/buffer.rs"); -include!("parts/text.rs"); diff --git a/crates/file_editor/src/parts/buffer.rs b/crates/file_editor/src/parts/buffer.rs deleted file mode 100644 index 956b6af0..00000000 --- a/crates/file_editor/src/parts/buffer.rs +++ /dev/null @@ -1,118 +0,0 @@ -impl Buffer { - pub(crate) fn new(id: BufferId, path: PathBuf, cx: &mut Context) -> Self { - let (content, disk_fingerprint) = read_disk_state(&path).unwrap_or_default(); - let language = language::from_path(&path); - Self { - id, - path, - language, - saved_content: content.clone(), - content, - disk_fingerprint, - dirty: false, - focus_handle: cx.focus_handle(), - } - } - - pub fn id(&self) -> BufferId { - self.id - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub fn language(&self) -> Option<&LanguageId> { - self.language.as_ref() - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn is_dirty(&self) -> bool { - self.dirty - } - - pub fn replace_range( - &mut self, - range: Range, - new_text: &str, - cx: &mut Context, - ) -> Range { - let range = clamp_range_to_char_boundaries(&self.content, range); - let new_text = normalize_newlines(new_text); - let new_end = range.start + new_text.len(); - if self.content[range.clone()] == new_text { - return range.start..new_end; - } - - self.content.replace_range(range.clone(), &new_text); - self.dirty = self.content != self.saved_content; - cx.notify(); - range.start..new_end - } - - pub fn save(&mut self, cx: &mut Context) -> std::io::Result<()> { - std::fs::write(&self.path, &self.content)?; - self.saved_content = self.content.clone(); - self.disk_fingerprint = disk_fingerprint(&self.path); - if self.dirty { - self.dirty = false; - cx.notify(); - } - Ok(()) - } - - fn refresh_from_disk_if_changed(&mut self, cx: &mut Context) { - if self.dirty { - return; - } - - let disk_fingerprint = disk_fingerprint(&self.path); - if disk_fingerprint == self.disk_fingerprint { - return; - } - - self.reload_from_disk(cx); - } - - fn reload_from_disk(&mut self, cx: &mut Context) { - if self.dirty { - return; - } - let Ok((content, disk_fingerprint)) = read_disk_state(&self.path) else { - return; - }; - if content == self.content { - self.saved_content = self.content.clone(); - self.disk_fingerprint = disk_fingerprint; - return; - } - - self.content = content; - self.saved_content = self.content.clone(); - self.disk_fingerprint = disk_fingerprint; - self.dirty = false; - cx.notify(); - } -} - -fn read_disk_state(path: &Path) -> std::io::Result<(String, Option)> { - let content = normalize_newlines(&std::fs::read_to_string(path)?); - Ok((content, disk_fingerprint(path))) -} - -fn disk_fingerprint(path: &Path) -> Option { - let metadata = std::fs::metadata(path).ok()?; - Some(DiskFingerprint { - len: metadata.len(), - modified: metadata.modified().ok(), - }) -} - -impl Focusable for Buffer { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} diff --git a/crates/file_editor/src/parts/model.rs b/crates/file_editor/src/parts/model.rs deleted file mode 100644 index 1173a411..00000000 --- a/crates/file_editor/src/parts/model.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use gpui::{App, Context, FocusHandle, Focusable, actions}; -use language::LanguageId; -use settings::{ActiveSettings, SettingValue}; - -actions!(file_editor, [Save]); - -/// Stable identifier for an open buffer. Issued by [`BufferStore`] and never -/// reused, so editor subsystems can hold onto an id across path changes, -/// untitled buffers, or multi-root collisions where two paths could otherwise -/// alias. -#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] -pub struct BufferId(u64); - -impl BufferId { - pub fn as_u64(self) -> u64 { - self.0 - } -} - -pub const SOFT_WRAP_SETTING_ID: &str = "editor.soft_wrap"; - -/// Resolve `editor.soft_wrap` from the global settings, falling back to the -/// default declared in `settings::registry::EDITOR`. -pub fn soft_wrap_enabled(cx: &App) -> bool { - cx.settings() - .get(SOFT_WRAP_SETTING_ID) - .and_then(SettingValue::as_bool) - .unwrap_or(false) -} - -/// In-memory view of a file open in an editor tab. Shared across all tabs -/// viewing the same path; per-tab editor state lives in the UI component store. -pub struct Buffer { - id: BufferId, - path: PathBuf, - language: Option, - content: String, - saved_content: String, - disk_fingerprint: Option, - dirty: bool, - focus_handle: FocusHandle, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DiskFingerprint { - len: u64, - modified: Option, -} diff --git a/crates/file_editor/src/parts/text.rs b/crates/file_editor/src/parts/text.rs deleted file mode 100644 index cd216474..00000000 --- a/crates/file_editor/src/parts/text.rs +++ /dev/null @@ -1,17 +0,0 @@ -fn normalize_newlines(text: &str) -> String { - text.replace("\r\n", "\n").replace('\r', "\n") -} - -fn clamp_to_char_boundary(content: &str, mut offset: usize) -> usize { - offset = offset.min(content.len()); - while offset > 0 && !content.is_char_boundary(offset) { - offset -= 1; - } - offset -} - -fn clamp_range_to_char_boundaries(content: &str, range: Range) -> Range { - let start = clamp_to_char_boundary(content, range.start); - let end = clamp_to_char_boundary(content, range.end); - start.min(end)..end.max(start) -} diff --git a/crates/file_editor/src/store.rs b/crates/file_editor/src/store.rs deleted file mode 100644 index fbf52cc2..00000000 --- a/crates/file_editor/src/store.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use gpui::{App, AppContext, BorrowAppContext, Entity, Global}; - -use crate::{Buffer, BufferId}; - -/// Global cache for open file buffers. Paths map to stable [`BufferId`]s, and -/// IDs map to GPUI entities so subsystems can either open by path or later -/// look buffers up by their stable id. -#[derive(Default)] -pub struct BufferStore { - next_id: u64, - by_path: HashMap, - by_id: HashMap>, -} - -impl BufferStore { - pub fn install(cx: &mut App) { - cx.set_global(Self::default()); - } - - /// Return the existing buffer for `path`, opening (and caching) one if - /// none exists yet. - pub fn open(path: PathBuf, cx: &mut App) -> Entity { - if let Some(existing) = cx - .try_global::() - .and_then(|s| s.by_path.get(&path).and_then(|id| s.by_id.get(id)).cloned()) - { - existing.update(cx, |buffer, cx| buffer.refresh_from_disk_if_changed(cx)); - return existing; - } - let id = cx.update_global::(|store, _| { - let id = BufferId(store.next_id); - store.next_id += 1; - id - }); - let path_for_buffer = path.clone(); - let entity = cx.new(move |cx| Buffer::new(id, path_for_buffer, cx)); - cx.update_global::(|store, _| { - store.by_path.insert(path, id); - store.by_id.insert(id, entity.clone()); - }); - entity - } - - pub fn get(id: BufferId, cx: &App) -> Option> { - cx.try_global::() - .and_then(|s| s.by_id.get(&id).cloned()) - } - - pub fn is_path_dirty(path: &Path, cx: &App) -> bool { - cx.try_global::() - .and_then(|store| { - store - .by_path - .get(path) - .and_then(|id| store.by_id.get(id)) - .cloned() - }) - .is_some_and(|buffer| buffer.read(cx).is_dirty()) - } - - pub fn content_for_path(path: &Path, cx: &App) -> Option { - cx.try_global::() - .and_then(|store| { - store - .by_path - .get(path) - .and_then(|id| store.by_id.get(id)) - .cloned() - }) - .map(|buffer| buffer.read(cx).content().to_string()) - } - - pub fn write_path_content( - path: &Path, - content: impl Into, - cx: &mut App, - ) -> std::io::Result<()> { - let content = content.into(); - let buffer = cx.try_global::().and_then(|store| { - store - .by_path - .get(path) - .and_then(|id| store.by_id.get(id)) - .cloned() - }); - - if let Some(buffer) = buffer { - buffer.update(cx, |buffer, cx| { - let len = buffer.content().len(); - buffer.replace_range(0..len, &content, cx); - buffer.save(cx) - })?; - return Ok(()); - } - - std::fs::write(path, content) - } - - pub fn has_dirty_buffers(cx: &App) -> bool { - cx.try_global::().is_some_and(|store| { - store - .by_id - .values() - .any(|buffer| buffer.read(cx).is_dirty()) - }) - } - - pub fn save_path(path: &Path, cx: &mut App) -> std::io::Result { - let buffer = cx.try_global::().and_then(|store| { - store - .by_path - .get(path) - .and_then(|id| store.by_id.get(id)) - .cloned() - }); - let Some(buffer) = buffer else { - return Ok(false); - }; - buffer.update(cx, |buffer, cx| buffer.save(cx))?; - Ok(true) - } - - pub fn save_all(cx: &mut App) -> std::io::Result { - let buffers = cx - .try_global::() - .map(|store| store.by_id.values().cloned().collect::>()) - .unwrap_or_default(); - let mut saved = 0; - for buffer in buffers { - if !buffer.read(cx).is_dirty() { - continue; - } - buffer.update(cx, |buffer, cx| buffer.save(cx))?; - saved += 1; - } - Ok(saved) - } - - pub fn reload_paths(paths: impl IntoIterator, cx: &mut App) { - let buffers = cx - .try_global::() - .map(|store| { - paths - .into_iter() - .filter_map(|path| { - store - .by_path - .get(&path) - .and_then(|id| store.by_id.get(id)) - .cloned() - }) - .collect::>() - }) - .unwrap_or_default(); - - for buffer in buffers { - buffer.update(cx, |buffer, cx| buffer.reload_from_disk(cx)); - } - } -} - -impl Global for BufferStore {} diff --git a/crates/file_search/Cargo.toml b/crates/file_search/Cargo.toml deleted file mode 100644 index fa729012..00000000 --- a/crates/file_search/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "file_search" -version = "0.4.9" -edition = "2024" - -[dependencies] -fff-search = { version = "0.8.4", features = ["zlob"] } diff --git a/crates/file_search/README.md b/crates/file_search/README.md deleted file mode 100644 index fa9660d4..00000000 --- a/crates/file_search/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# file_search - -Role: project-wide file search domain. - -Owns: -- Future search query models. -- Future search indexing or traversal logic. -- Future search results independent of UI rendering. - -Does Not Own: -- Search tab UI. -- Workspace or pane orchestration. -- File tree state. - -Status: placeholder crate; current code is a scaffold only. diff --git a/crates/file_search/src/lib.rs b/crates/file_search/src/lib.rs deleted file mode 100644 index 45a6e228..00000000 --- a/crates/file_search/src/lib.rs +++ /dev/null @@ -1,348 +0,0 @@ -use std::{ - collections::hash_map::DefaultHasher, - fmt, - hash::{Hash, Hasher}, - path::{Path, PathBuf}, -}; - -use fff_search::{ - FFFMode, FilePicker, FilePickerOptions, FileSearchConfig, FrecencyTracker, FuzzySearchOptions, - GrepConfig, GrepMode, GrepSearchOptions, PaginationArgs, QueryParser, QueryTracker, - SharedFilePicker, SharedFrecency, SharedQueryTracker, -}; - -const CONTENT_SEARCH_TIME_BUDGET_MS: u64 = 150; - -#[derive(Debug)] -pub enum Error { - FilePickerMissing, - Fff(fff_search::Error), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::FilePickerMissing => write!(f, "file search index is not available"), - Self::Fff(error) => write!(f, "{error}"), - } - } -} - -impl std::error::Error for Error {} - -impl From for Error { - fn from(error: fff_search::Error) -> Self { - Self::Fff(error) - } -} - -#[derive(Clone, Debug)] -pub struct FileSearchResult { - pub name: String, - pub relative_path: String, - pub absolute_path: PathBuf, - pub score: i32, - pub size: u64, - pub is_binary: bool, -} - -#[derive(Clone, Debug)] -pub struct FileSearchSnapshot { - pub query: String, - pub total_matched: usize, - pub total_files: usize, - pub scanned_files_count: usize, - pub is_scanning: bool, - pub is_watcher_ready: bool, - pub results: Vec, -} - -#[derive(Clone, Debug)] -pub struct ContentSearchResult { - pub name: String, - pub relative_path: String, - pub absolute_path: PathBuf, - pub line_number: u64, - pub column: usize, - pub byte_offset: u64, - pub line_content: String, - pub match_byte_offsets: Vec<(u32, u32)>, - pub size: u64, - pub is_binary: bool, -} - -#[derive(Clone, Debug)] -pub struct ContentSearchSnapshot { - pub query: String, - pub total_files: usize, - pub scanned_files_count: usize, - pub is_scanning: bool, - pub is_watcher_ready: bool, - pub total_files_searched: usize, - pub filtered_file_count: usize, - pub files_with_matches: usize, - pub regex_fallback_error: Option, - pub results: Vec, -} - -pub struct FileSearchIndex { - root: PathBuf, - picker: SharedFilePicker, - frecency: SharedFrecency, - query_tracker: SharedQueryTracker, -} - -impl FileSearchIndex { - pub fn new(root: PathBuf) -> Result { - let picker = SharedFilePicker::default(); - let frecency = SharedFrecency::default(); - let query_tracker = SharedQueryTracker::default(); - - install_persistent_trackers(&root, &frecency, &query_tracker); - - FilePicker::new_with_shared_state( - picker.clone(), - frecency.clone(), - FilePickerOptions { - base_path: root.to_string_lossy().into_owned(), - mode: FFFMode::Neovim, - watch: true, - follow_symlinks: false, - enable_mmap_cache: false, - enable_content_indexing: true, - ..Default::default() - }, - )?; - - Ok(Self { - root, - picker, - frecency, - query_tracker, - }) - } - - pub fn root(&self) -> &Path { - &self.root - } - - pub fn status(&self, query: &str) -> Result { - let picker_guard = self.picker.read()?; - let picker = picker_guard.as_ref().ok_or(Error::FilePickerMissing)?; - let progress = picker.get_scan_progress(); - - Ok(FileSearchSnapshot { - query: query.to_string(), - total_matched: 0, - total_files: picker.live_file_count(), - scanned_files_count: progress.scanned_files_count, - is_scanning: progress.is_scanning || picker.is_post_scan_active(), - is_watcher_ready: progress.is_watcher_ready, - results: Vec::new(), - }) - } - - pub fn search(&self, query: &str, limit: usize) -> Result { - if query.trim().is_empty() { - return self.status(query); - } - - let picker_guard = self.picker.read()?; - let picker = picker_guard.as_ref().ok_or(Error::FilePickerMissing)?; - let progress = picker.get_scan_progress(); - let parser: QueryParser = QueryParser::default(); - let parsed_query = parser.parse(query); - let query_tracker_guard = self.query_tracker.read()?; - let result = picker.fuzzy_search( - &parsed_query, - query_tracker_guard.as_ref(), - FuzzySearchOptions { - max_threads: search_threads(), - current_file: None, - project_path: Some(&self.root), - combo_boost_score_multiplier: 100, - min_combo_count: 2, - pagination: PaginationArgs { offset: 0, limit }, - }, - ); - - let results = result - .items - .iter() - .zip(result.scores.iter()) - .map(|(item, score)| FileSearchResult { - name: item.file_name(picker), - relative_path: item.relative_path(picker), - absolute_path: item.absolute_path(picker, picker.base_path()), - score: score.total, - size: item.size, - is_binary: item.is_binary(), - }) - .collect(); - - Ok(FileSearchSnapshot { - query: query.to_string(), - total_matched: result.total_matched, - total_files: result.total_files, - scanned_files_count: progress.scanned_files_count, - is_scanning: progress.is_scanning || picker.is_post_scan_active(), - is_watcher_ready: progress.is_watcher_ready, - results, - }) - } - - pub fn content_status(&self, query: &str) -> Result { - let picker_guard = self.picker.read()?; - let picker = picker_guard.as_ref().ok_or(Error::FilePickerMissing)?; - let progress = picker.get_scan_progress(); - - Ok(ContentSearchSnapshot { - query: query.to_string(), - total_files: picker.live_file_count(), - scanned_files_count: progress.scanned_files_count, - is_scanning: progress.is_scanning || picker.is_post_scan_active(), - is_watcher_ready: progress.is_watcher_ready, - total_files_searched: 0, - filtered_file_count: 0, - files_with_matches: 0, - regex_fallback_error: None, - results: Vec::new(), - }) - } - - pub fn search_content( - &self, - query: &str, - limit: usize, - ) -> Result { - if query.trim().is_empty() { - return self.content_status(query); - } - - let picker_guard = self.picker.read()?; - let picker = picker_guard.as_ref().ok_or(Error::FilePickerMissing)?; - let progress = picker.get_scan_progress(); - let parser: QueryParser = QueryParser::new(GrepConfig); - let parsed_query = parser.parse(query); - let result = picker.grep( - &parsed_query, - &GrepSearchOptions { - page_limit: limit, - max_matches_per_file: 4, - mode: GrepMode::PlainText, - time_budget_ms: content_search_time_budget(limit), - ..Default::default() - }, - ); - - let results = result - .matches - .iter() - .filter_map(|item_match| { - let item = result.files.get(item_match.file_index)?; - Some(ContentSearchResult { - name: item.file_name(picker), - relative_path: item.relative_path(picker), - absolute_path: item.absolute_path(picker, picker.base_path()), - line_number: item_match.line_number, - column: item_match.col, - byte_offset: item_match.byte_offset, - line_content: item_match.line_content.clone(), - match_byte_offsets: item_match - .match_byte_offsets - .iter() - .map(|range| (range.0, range.1)) - .collect(), - size: item.size, - is_binary: item.is_binary(), - }) - }) - .collect(); - - Ok(ContentSearchSnapshot { - query: query.to_string(), - total_files: result.total_files, - scanned_files_count: progress.scanned_files_count, - is_scanning: progress.is_scanning || picker.is_post_scan_active(), - is_watcher_ready: progress.is_watcher_ready, - total_files_searched: result.total_files_searched, - filtered_file_count: result.filtered_file_count, - files_with_matches: result.files_with_matches, - regex_fallback_error: result.regex_fallback_error, - results, - }) - } - - pub fn track_open(&self, query: &str, path: &Path) { - if let Ok(frecency_guard) = self.frecency.read() - && let Some(frecency) = frecency_guard.as_ref() - { - let _ = frecency.track_access(path); - if let Ok(mut picker_guard) = self.picker.write() - && let Some(picker) = picker_guard.as_mut() - { - let _ = picker.update_single_file_frecency(path, frecency); - } - } - - let query = query.trim(); - if query.is_empty() { - return; - } - - if let Ok(mut query_tracker_guard) = self.query_tracker.write() - && let Some(query_tracker) = query_tracker_guard.as_mut() - { - let _ = query_tracker.track_query_completion(query, &self.root, path); - } - } -} - -fn search_threads() -> usize { - 1 -} - -fn content_search_time_budget(limit: usize) -> u64 { - let extra_pages = (limit / 50).saturating_sub(1) as u64; - CONTENT_SEARCH_TIME_BUDGET_MS - .saturating_add(extra_pages.saturating_mul(75)) - .min(750) -} - -fn install_persistent_trackers( - root: &Path, - frecency: &SharedFrecency, - query_tracker: &SharedQueryTracker, -) { - let Some(cache_dir) = workspace_cache_dir(root) else { - return; - }; - - if let Ok(tracker) = FrecencyTracker::open(cache_dir.join("frecency")) { - let _ = frecency.init(tracker); - } - - if let Ok(tracker) = QueryTracker::open(cache_dir.join("queries")) { - let _ = query_tracker.init(tracker); - } -} - -fn workspace_cache_dir(root: &Path) -> Option { - let mut hasher = DefaultHasher::new(); - root.hash(&mut hasher); - let key = format!("{:016x}", hasher.finish()); - let dir = cache_home()?.join("kosmos").join("fff").join(key); - std::fs::create_dir_all(&dir).ok()?; - Some(dir) -} - -fn cache_home() -> Option { - if let Some(path) = std::env::var_os("XDG_CACHE_HOME") { - return Some(PathBuf::from(path)); - } - - std::env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join(".cache")) - .or_else(|| Some(std::env::temp_dir())) -} diff --git a/crates/file_tree/Cargo.toml b/crates/file_tree/Cargo.toml deleted file mode 100644 index 076327ed..00000000 --- a/crates/file_tree/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "file_tree" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -notify = "8" -trash = "5" diff --git a/crates/file_tree/README.md b/crates/file_tree/README.md deleted file mode 100644 index f7024f45..00000000 --- a/crates/file_tree/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# file_tree - -Role: file tree state and file-system operations. - -Owns: -- Active file tree entity state. -- Directory loading, expansion, selection, clipboard, rename, and new-entry state. -- File-system mutation helpers for create, rename, move, paste, delete, and trash. -- Recursive file-system watching and refresh events. - -Does Not Own: -- File tree rendering. -- Workspace selection or root discovery policy. -- Opening files into panes. -- Editor buffer contents. diff --git a/crates/file_tree/src/lib.rs b/crates/file_tree/src/lib.rs deleted file mode 100644 index 5a1c9c22..00000000 --- a/crates/file_tree/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod ops; -mod state; -mod watcher; - -pub use ops::ClipboardOp; -pub use state::{ - ActiveFileTree, FileTree, FileTreeEvent, FileTreeState, FileTreeUiActions, NewEntryDraft, Node, - NodeKind, RenameTarget, -}; diff --git a/crates/file_tree/src/ops.rs b/crates/file_tree/src/ops.rs deleted file mode 100644 index 641141d3..00000000 --- a/crates/file_tree/src/ops.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::cmp::Ordering; -use std::fs; -use std::io; -use std::path::Path; - -use crate::state::{Node, NodeKind}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ClipboardOp { - Cut, - Copy, -} - -pub fn read_dir(path: &Path) -> io::Result> { - let mut nodes: Vec = Vec::new(); - for entry in fs::read_dir(path)? { - let entry = entry?; - let file_type = match entry.file_type() { - Ok(t) => t, - Err(_) => continue, - }; - let name = entry.file_name().to_string_lossy().into_owned(); - let kind = if file_type.is_dir() { - NodeKind::Directory - } else { - NodeKind::File - }; - nodes.push(Node { - path: entry.path(), - name: name.into(), - kind, - }); - } - nodes.sort_by(|a, b| match (a.kind, b.kind) { - (NodeKind::Directory, NodeKind::File) => Ordering::Less, - (NodeKind::File, NodeKind::Directory) => Ordering::Greater, - _ => natural_cmp(a.name.as_ref(), b.name.as_ref()), - }); - Ok(nodes) -} - -fn natural_cmp(a: &str, b: &str) -> Ordering { - a.to_lowercase().cmp(&b.to_lowercase()) -} - -pub fn create_file(path: &Path) -> io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map(|_| ()) -} - -pub fn create_dir(path: &Path) -> io::Result<()> { - fs::create_dir_all(path) -} - -pub fn rename(from: &Path, to: &Path) -> io::Result<()> { - fs::rename(from, to) -} - -pub fn delete(path: &Path) -> io::Result<()> { - if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } -} - -pub fn trash(path: &Path) -> io::Result<()> { - trash::delete(path).map_err(|err| io::Error::other(err.to_string())) -} - -pub fn move_into(src: &Path, dest_dir: &Path) -> io::Result<()> { - let Some(name) = src.file_name() else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "source has no file name", - )); - }; - let dest = dest_dir.join(name); - if dest == src { - return Ok(()); - } - if dest.exists() { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("{} already exists", dest.display()), - )); - } - fs::rename(src, &dest) -} - -pub fn paste(src: &Path, dest_dir: &Path, op: ClipboardOp) -> io::Result<()> { - let Some(name) = src.file_name() else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "source has no file name", - )); - }; - let dest = unique_destination(dest_dir, name.to_string_lossy().as_ref()); - match op { - ClipboardOp::Cut => { - if dest == src { - return Ok(()); - } - fs::rename(src, &dest) - } - ClipboardOp::Copy => copy_recursive(src, &dest), - } -} - -fn unique_destination(dir: &Path, name: &str) -> std::path::PathBuf { - let mut candidate = dir.join(name); - if !candidate.exists() { - return candidate; - } - let (stem, ext) = match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), Some(ext.to_string())), - _ => (name.to_string(), None), - }; - let mut counter = 1usize; - loop { - let new_name = match &ext { - Some(ext) => format!("{stem} (copy {counter}).{ext}"), - None => format!("{stem} (copy {counter})"), - }; - candidate = dir.join(new_name); - if !candidate.exists() { - return candidate; - } - counter += 1; - } -} - -fn copy_recursive(src: &Path, dest: &Path) -> io::Result<()> { - if src.is_dir() { - fs::create_dir_all(dest)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let child_dest = dest.join(entry.file_name()); - copy_recursive(&entry.path(), &child_dest)?; - } - Ok(()) - } else { - fs::copy(src, dest).map(|_| ()) - } -} diff --git a/crates/file_tree/src/state.rs b/crates/file_tree/src/state.rs deleted file mode 100644 index 09a07e8e..00000000 --- a/crates/file_tree/src/state.rs +++ /dev/null @@ -1,6 +0,0 @@ -include!("state/types.rs"); -include!("state/root_and_watch.rs"); -include!("state/selection.rs"); -include!("state/operations.rs"); -include!("state/entry_editing.rs"); -include!("state/active.rs"); diff --git a/crates/file_tree/src/state/active.rs b/crates/file_tree/src/state/active.rs deleted file mode 100644 index 00c99c4a..00000000 --- a/crates/file_tree/src/state/active.rs +++ /dev/null @@ -1,40 +0,0 @@ -/// Global wrapper around the active workspace's `FileTree` entity. -#[derive(Default)] -pub struct FileTreeState { - active: Option>, -} - -impl FileTreeState { - pub fn new() -> Self { - Self::default() - } - - pub fn set_active(&mut self, entity: Option>) { - self.active = entity; - } - - pub fn active(&self) -> Option<&Entity> { - self.active.as_ref() - } -} - -impl EventEmitter for FileTree {} - -impl Global for FileTreeState {} - -pub trait ActiveFileTree { - fn file_tree(&self) -> Option<&Entity>; -} - -impl ActiveFileTree for App { - fn file_tree(&self) -> Option<&Entity> { - self.try_global::() - .and_then(|s| s.active.as_ref()) - } -} - -/// Marker trait that signals an app-level type can mutate the file tree state -/// in response to UI events. Currently empty — actions are dispatched directly -/// against the `FileTree` entity, so the trait exists only for any future -/// app-level callbacks we want to plug in (e.g. opening a file in the editor). -pub trait FileTreeUiActions: Sized + 'static {} diff --git a/crates/file_tree/src/state/entry_editing.rs b/crates/file_tree/src/state/entry_editing.rs deleted file mode 100644 index 89710c4e..00000000 --- a/crates/file_tree/src/state/entry_editing.rs +++ /dev/null @@ -1,118 +0,0 @@ -impl FileTree { - fn deselect_path(&mut self, path: &Path) { - self.selected.remove(path); - if self.selection_anchor.as_deref() == Some(path) { - self.selection_anchor = None; - } - } - - pub fn start_rename(&mut self, path: PathBuf, cx: &mut Context) { - let original = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - // Renames operate on a single target — narrow the selection to it so - // the active row matches what's being renamed. - self.selected.clear(); - self.selected.insert(path.clone()); - self.selection_anchor = Some(path.clone()); - self.rename = Some(RenameTarget { - path, - original_name: original.into(), - }); - cx.notify(); - } - - pub fn cancel_rename(&mut self, cx: &mut Context) { - if self.rename.take().is_some() { - cx.notify(); - } - } - - pub fn apply_rename(&mut self, new_name: String, cx: &mut Context) { - let Some(target) = self.rename.take() else { - return; - }; - let trimmed = new_name.trim(); - if trimmed.is_empty() || trimmed == target.original_name.as_ref() { - cx.notify(); - return; - } - let Some(parent) = target.path.parent() else { - return; - }; - let new_path = parent.join(trimmed); - match ops::rename(&target.path, &new_path) { - Ok(_) => { - if self.children.contains_key(parent) { - self.reload_dir(parent); - } - if self.selected.remove(&target.path) { - self.selected.insert(new_path.clone()); - } - if self.selection_anchor.as_deref() == Some(target.path.as_path()) { - self.selection_anchor = Some(new_path); - } - cx.notify(); - } - Err(err) => self.set_error(format!("Rename failed: {err}"), cx), - } - } - - pub fn start_new_entry( - &mut self, - anchor: Option<&Path>, - kind: NodeKind, - cx: &mut Context, - ) { - let parent = match anchor { - Some(path) if path.is_dir() => path.to_path_buf(), - Some(path) => path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| self.root.clone().unwrap_or_default()), - None => self.root.clone().unwrap_or_default(), - }; - self.expand(&parent, cx); - self.new_entry = Some(NewEntryDraft { parent, kind }); - cx.notify(); - } - - pub fn cancel_new_entry(&mut self, cx: &mut Context) { - if self.new_entry.take().is_some() { - cx.notify(); - } - } - - pub fn apply_new_entry(&mut self, name: String, cx: &mut Context) { - let Some(draft) = self.new_entry.take() else { - return; - }; - let trimmed = name.trim(); - if trimmed.is_empty() { - cx.notify(); - return; - } - let path = draft.parent.join(trimmed); - let result = match draft.kind { - NodeKind::File => ops::create_file(&path), - NodeKind::Directory => ops::create_dir(&path), - }; - match result { - Ok(_) => { - self.reload_dir(&draft.parent); - self.selected.clear(); - self.selected.insert(path.clone()); - self.selection_anchor = Some(path); - cx.notify(); - } - Err(err) => self.set_error(format!("Create failed: {err}"), cx), - } - } - - fn set_error(&mut self, message: String, cx: &mut Context) { - self.error = Some(message.into()); - cx.notify(); - } -} diff --git a/crates/file_tree/src/state/operations.rs b/crates/file_tree/src/state/operations.rs deleted file mode 100644 index 433239ed..00000000 --- a/crates/file_tree/src/state/operations.rs +++ /dev/null @@ -1,131 +0,0 @@ -impl FileTree { - pub fn cut(&mut self, paths: Vec, cx: &mut Context) { - if paths.is_empty() { - return; - } - self.clipboard = Some((ClipboardOp::Cut, paths)); - cx.notify(); - } - - pub fn copy(&mut self, paths: Vec, cx: &mut Context) { - if paths.is_empty() { - return; - } - self.clipboard = Some((ClipboardOp::Copy, paths)); - cx.notify(); - } - - pub fn paste_into(&mut self, dest_dir: PathBuf, cx: &mut Context) { - let Some((op, srcs)) = self.clipboard.clone() else { - return; - }; - let Some(target_dir) = Self::operation_target_dir(dest_dir) else { - return; - }; - let mut errors: Vec = Vec::new(); - let mut src_parents: HashSet = HashSet::new(); - for src in &srcs { - if let Err(err) = ops::paste(src, &target_dir, op) { - errors.push(format!("{}: {err}", src.display())); - } - Self::collect_parent(&mut src_parents, src); - } - if op == ClipboardOp::Cut { - self.clipboard = None; - } - self.reload_dir(&target_dir); - self.reload_known_parents(src_parents); - self.finish_fs_operation("Paste", errors, cx); - } - - pub fn move_into(&mut self, srcs: Vec, dest_dir: PathBuf, cx: &mut Context) { - let Some(target_dir) = Self::operation_target_dir(dest_dir) else { - return; - }; - let mut errors: Vec = Vec::new(); - let mut parents: HashSet = HashSet::new(); - for src in &srcs { - // Skip no-ops and pathological cases instead of erroring. - if *src == target_dir - || target_dir.ancestors().any(|a| a == src.as_path()) - || src.parent() == Some(target_dir.as_path()) - { - continue; - } - if let Err(err) = ops::move_into(src, &target_dir) { - errors.push(format!("{}: {err}", src.display())); - continue; - } - Self::collect_parent(&mut parents, src); - } - self.reload_dir(&target_dir); - self.reload_known_parents(parents); - self.finish_fs_operation("Move", errors, cx); - } - - pub fn trash(&mut self, paths: Vec, cx: &mut Context) { - self.remove_paths(paths, "Trash", ops::trash, cx); - } - - pub fn delete(&mut self, paths: Vec, cx: &mut Context) { - self.remove_paths(paths, "Delete", ops::delete, cx); - } - - fn remove_paths( - &mut self, - paths: Vec, - label: &'static str, - operation: impl Fn(&Path) -> std::io::Result<()>, - cx: &mut Context, - ) { - let mut errors: Vec = Vec::new(); - let mut parents: HashSet = HashSet::new(); - for path in &paths { - match operation(path) { - Ok(_) => { - Self::collect_parent(&mut parents, path); - self.deselect_path(path); - } - Err(err) => errors.push(format!("{}: {err}", path.display())), - } - } - self.reload_known_parents(parents); - self.finish_fs_operation(label, errors, cx); - } - - fn operation_target_dir(dest_dir: PathBuf) -> Option { - if dest_dir.is_dir() { - Some(dest_dir) - } else { - dest_dir.parent().map(Path::to_path_buf) - } - } - - fn collect_parent(parents: &mut HashSet, path: &Path) { - if let Some(parent) = path.parent() { - parents.insert(parent.to_path_buf()); - } - } - - fn reload_known_parents(&mut self, parents: HashSet) { - for parent in parents { - if self.children.contains_key(&parent) { - self.reload_dir(&parent); - } - } - } - - fn finish_fs_operation( - &mut self, - label: &'static str, - errors: Vec, - cx: &mut Context, - ) { - if !errors.is_empty() { - self.set_error(format!("{label} failed: {}", errors.join("; ")), cx); - } else { - cx.notify(); - } - } - -} diff --git a/crates/file_tree/src/state/root_and_watch.rs b/crates/file_tree/src/state/root_and_watch.rs deleted file mode 100644 index a5ef33c8..00000000 --- a/crates/file_tree/src/state/root_and_watch.rs +++ /dev/null @@ -1,217 +0,0 @@ -impl FileTree { - pub fn new(cx: &mut Context) -> Self { - let _ = cx; - Self { - root: None, - children: HashMap::new(), - expanded: HashSet::new(), - selected: HashSet::new(), - selection_anchor: None, - clipboard: None, - error: None, - rename: None, - new_entry: None, - watcher: None, - watcher_rx: None, - watcher_task: None, - } - } - - pub fn root(&self) -> Option<&Path> { - self.root.as_deref() - } - - /// Returns the anchor of the current selection — the last item clicked - /// without a shift modifier. Useful for callers that need a single - /// reference point (e.g. "new file" anchored on the active item). - pub fn selected(&self) -> Option<&Path> { - self.selection_anchor.as_deref() - } - - pub fn is_selected(&self, path: &Path) -> bool { - self.selected.contains(path) - } - - pub fn selected_paths(&self) -> &HashSet { - &self.selected - } - - pub fn selected_count(&self) -> usize { - self.selected.len() - } - - pub fn error(&self) -> Option<&SharedString> { - self.error.as_ref() - } - - pub fn clear_error(&mut self) { - self.error = None; - } - - pub fn clipboard(&self) -> Option<(ClipboardOp, &[PathBuf])> { - self.clipboard - .as_ref() - .map(|(op, paths)| (*op, paths.as_slice())) - } - - pub fn rename_target(&self) -> Option<&RenameTarget> { - self.rename.as_ref() - } - - pub fn new_entry_draft(&self) -> Option<&NewEntryDraft> { - self.new_entry.as_ref() - } - - pub fn is_expanded(&self, path: &Path) -> bool { - self.expanded.contains(path) - } - - pub fn expanded_paths(&self) -> Vec { - self.expanded.iter().cloned().collect() - } - - fn emit_expanded_changed(&self, cx: &mut Context) { - cx.emit(FileTreeEvent::ExpandedChanged { - root: self.root.clone(), - paths: self.expanded_paths(), - }); - } - - pub fn children_of(&self, path: &Path) -> Option<&[Node]> { - self.children.get(path).map(|v| v.as_slice()) - } - - /// Set or replace the root path. Drops any existing watcher and reloads - /// the top-level directory listing, then installs a recursive watcher. - pub fn set_root(&mut self, root: PathBuf, expanded: Vec, cx: &mut Context) { - if Some(&root) == self.root.as_ref() { - return; - } - self.children.clear(); - self.expanded.clear(); - self.selected.clear(); - self.selection_anchor = None; - self.error = None; - self.rename = None; - self.new_entry = None; - // Drop the previous watcher off the main thread — releasing inotify - // watches for a large tree can take seconds. - if let Some(old) = self.watcher.take() { - cx.background_executor() - .spawn(async move { drop(old) }) - .detach(); - } - self.watcher_rx = None; - self.watcher_task = None; - - self.root = Some(root.clone()); - self.expanded = expanded - .into_iter() - .filter(|path| path.starts_with(&root)) - .collect(); - if self.expanded.is_empty() { - self.expanded.insert(root.clone()); - } - self.reload_dir(&root); - let mut expanded_dirs = self.expanded.iter().cloned().collect::>(); - expanded_dirs.sort(); - for dir in expanded_dirs { - if dir == root { - continue; - } - if dir.is_dir() { - self.reload_dir(&dir); - } else { - self.expanded.remove(&dir); - } - } - - // Set up the events channel and start polling synchronously, but - // install the watcher itself off the main thread — walking a big tree - // and adding an inotify watch per directory can take several seconds. - let (events_tx, events_rx) = channel::(); - self.watcher_rx = Some(events_rx); - self.spawn_poll_task(cx); - self.spawn_watcher_install(root, events_tx, cx); - - cx.notify(); - } - - fn spawn_watcher_install( - &mut self, - root: PathBuf, - events_tx: Sender, - cx: &mut Context, - ) { - cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { watcher::start(&root, events_tx) }) - .await; - let _ = this.update(cx, |tree, cx| match result { - Ok(w) => tree.watcher = Some(w), - Err(err) => { - tree.error = Some(format!("Watcher failed: {err}").into()); - cx.notify(); - } - }); - }) - .detach(); - } - - fn spawn_poll_task(&mut self, cx: &mut Context) { - let task = cx.spawn(async move |this, cx| { - loop { - let timer = cx.background_executor().timer(Duration::from_millis(150)); - timer.await; - - let mut paths_to_refresh: HashSet = HashSet::new(); - let mut affected_paths: HashSet = HashSet::new(); - let drained = this - .update(cx, |tree, _| { - let Some(rx) = tree.watcher_rx.as_ref() else { - return false; - }; - while let Ok(events) = rx.try_recv() { - for path in events.affected_paths { - affected_paths.insert(path.clone()); - if let Some(parent) = path.parent() { - paths_to_refresh.insert(parent.to_path_buf()); - } - if path.is_dir() { - paths_to_refresh.insert(path); - } - } - } - true - }) - .ok(); - - if drained.is_none() { - break; - } - - if !affected_paths.is_empty() { - let paths = affected_paths.into_iter().collect::>(); - let _ = this.update(cx, |_, cx| cx.emit(FileTreeEvent::FsChanged { paths })); - } - - if !paths_to_refresh.is_empty() { - let _ = this.update(cx, |tree, cx| { - let mut changed = false; - for path in paths_to_refresh { - if tree.children.contains_key(&path) { - tree.reload_dir(&path); - changed = true; - } - } - if changed { - cx.notify(); - } - }); - } - } - }); - self.watcher_task = Some(task); - } -} diff --git a/crates/file_tree/src/state/selection.rs b/crates/file_tree/src/state/selection.rs deleted file mode 100644 index 872093fd..00000000 --- a/crates/file_tree/src/state/selection.rs +++ /dev/null @@ -1,122 +0,0 @@ -impl FileTree { - /// Re-read a directory's contents from disk into our cache. - pub fn reload_dir(&mut self, path: &Path) { - match ops::read_dir(path) { - Ok(nodes) => { - self.children.insert(path.to_path_buf(), nodes); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - // The directory was removed (by us or externally). Drop it - // from the cache so we don't keep trying to read it. - self.children.remove(path); - self.expanded.remove(path); - } - Err(err) => { - self.error = Some(format!("Failed to read {}: {err}", path.display()).into()); - self.children.insert(path.to_path_buf(), Vec::new()); - } - } - } - - pub fn collapse_all(&mut self, cx: &mut Context) { - let mut expanded = HashSet::new(); - if let Some(root) = self.root.clone() { - expanded.insert(root); - } - if self.expanded == expanded { - return; - } - self.expanded = expanded; - self.emit_expanded_changed(cx); - cx.notify(); - } - - pub fn toggle_expand(&mut self, path: &Path, cx: &mut Context) { - if self.expanded.contains(path) { - self.expanded.remove(path); - } else { - self.expanded.insert(path.to_path_buf()); - if !self.children.contains_key(path) { - self.reload_dir(path); - } - } - self.emit_expanded_changed(cx); - cx.notify(); - } - - pub fn expand(&mut self, path: &Path, cx: &mut Context) { - if !self.expanded.contains(path) { - self.expanded.insert(path.to_path_buf()); - if !self.children.contains_key(path) { - self.reload_dir(path); - } - self.emit_expanded_changed(cx); - cx.notify(); - } - } - - pub fn select(&mut self, path: PathBuf, cx: &mut Context) { - self.selected.clear(); - self.selected.insert(path.clone()); - self.selection_anchor = Some(path); - cx.notify(); - } - - /// Extend the selection to cover the visible range from the anchor to - /// `target`. If there's no anchor yet, behaves like a plain select. - pub fn extend_selection_to(&mut self, target: PathBuf, cx: &mut Context) { - let anchor = match self.selection_anchor.clone() { - Some(a) => a, - None => { - self.select(target, cx); - return; - } - }; - let visible = self.visible_paths(); - let i_anchor = visible.iter().position(|p| p == &anchor); - let i_target = visible.iter().position(|p| p == &target); - match (i_anchor, i_target) { - (Some(a), Some(b)) => { - let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; - self.selected.clear(); - for p in &visible[lo..=hi] { - self.selected.insert(p.clone()); - } - cx.notify(); - } - _ => self.select(target, cx), - } - } - - /// Flatten the visible tree into a top-to-bottom path list, matching the - /// rendering order in the UI (root → expanded children, dirs before files). - pub fn visible_paths(&self) -> Vec { - let mut out = Vec::new(); - if let Some(root) = &self.root { - out.push(root.clone()); - if self.expanded.contains(root) { - self.append_visible_children(root, &mut out); - } - } - out - } - - fn append_visible_children(&self, dir: &Path, out: &mut Vec) { - let Some(children) = self.children.get(dir) else { - return; - }; - for node in children - .iter() - .filter(|n| matches!(n.kind, NodeKind::Directory)) - { - out.push(node.path.clone()); - if self.expanded.contains(&node.path) { - self.append_visible_children(&node.path, out); - } - } - for node in children.iter().filter(|n| matches!(n.kind, NodeKind::File)) { - out.push(node.path.clone()); - } - } - -} diff --git a/crates/file_tree/src/state/types.rs b/crates/file_tree/src/state/types.rs deleted file mode 100644 index 04a1997d..00000000 --- a/crates/file_tree/src/state/types.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Duration; - -use gpui::{App, Context, Entity, EventEmitter, Global, SharedString, Task}; - -use crate::ops::{self, ClipboardOp}; -use crate::watcher::{self, FsEvents}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NodeKind { - File, - Directory, -} - -#[derive(Clone)] -pub struct Node { - pub path: PathBuf, - pub name: SharedString, - pub kind: NodeKind, -} - -#[derive(Clone)] -pub struct RenameTarget { - pub path: PathBuf, - pub original_name: SharedString, -} - -#[derive(Clone)] -pub struct NewEntryDraft { - pub parent: PathBuf, - pub kind: NodeKind, -} - -pub struct FileTree { - root: Option, - children: HashMap>, - expanded: HashSet, - selected: HashSet, - selection_anchor: Option, - clipboard: Option<(ClipboardOp, Vec)>, - error: Option, - rename: Option, - new_entry: Option, - - // Watcher resources kept alive for the lifetime of the active root. - watcher: Option, - watcher_rx: Option>, - watcher_task: Option>, -} - -#[derive(Clone, Debug)] -pub enum FileTreeEvent { - FsChanged { paths: Vec }, - ExpandedChanged { - root: Option, - paths: Vec, - }, -} diff --git a/crates/file_tree/src/watcher.rs b/crates/file_tree/src/watcher.rs deleted file mode 100644 index 51638bc7..00000000 --- a/crates/file_tree/src/watcher.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::mpsc::Sender; - -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; - -pub struct FsEvents { - pub affected_paths: Vec, -} - -/// Directory names we don't recursively watch. They tend to be huge, change -/// constantly in ways the user doesn't care about in the file tree, and on -/// Linux each subdirectory costs an inotify watch. -const SKIP_DIRS: &[&str] = &[ - ".git", - "node_modules", - "target", - "dist", - "build", - ".next", - ".nuxt", - ".turbo", - ".cache", - ".venv", - "venv", - "__pycache__", - "vendor", -]; - -/// Install a recursive watch on `root`, skipping known-heavy directories. -/// -/// On Linux the `notify` crate emulates recursive watches by walking the tree -/// and installing one inotify watch per directory. For a workspace with -/// `node_modules`/`target`/etc. that walk can take many seconds and may exhaust -/// `fs.inotify.max_user_watches`. Callers should run this off the main thread. -pub fn start(root: &Path, tx: Sender) -> notify::Result { - let mut watcher = notify::recommended_watcher(move |result: notify::Result| { - let Ok(event) = result else { - return; - }; - let _ = tx.send(FsEvents { - affected_paths: event.paths, - }); - })?; - watch_tree(&mut watcher, root)?; - Ok(watcher) -} - -fn watch_tree(watcher: &mut RecommendedWatcher, dir: &Path) -> notify::Result<()> { - watcher.watch(dir, RecursiveMode::NonRecursive)?; - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return Ok(()), - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - // file_type from read_dir doesn't follow symlinks, so symlinked dirs - // report is_dir() == false — we naturally skip them and avoid cycles. - if !file_type.is_dir() { - continue; - } - let name = entry.file_name(); - if SKIP_DIRS - .iter() - .any(|s| *s == name.to_string_lossy().as_ref()) - { - continue; - } - // Best-effort: a child failing to watch (e.g. raced delete, permission - // denied) shouldn't abort the rest of the install. - let _ = watch_tree(watcher, &entry.path()); - } - Ok(()) -} diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml deleted file mode 100644 index b22162eb..00000000 --- a/crates/git/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "git" -version = "0.4.9" -edition = "2024" - -[dependencies] -gix = { workspace = true } diff --git a/crates/git/README.md b/crates/git/README.md deleted file mode 100644 index 5524811c..00000000 --- a/crates/git/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# git - -Role: Git repository operations. - -Owns: -- Repository discovery and summary data. -- Branch, remote, tag, stash, and changed-file domain models. -- Git actions such as stage, unstage, stash, branch checkout, and branch creation. -- Translation of `gix` and `git` command output into Kosmos data structures. - -Does Not Own: -- Git tab rendering. -- Workspace or file tree state. -- Application-level error presentation. diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs deleted file mode 100644 index 1a13b01d..00000000 --- a/crates/git/src/lib.rs +++ /dev/null @@ -1,1626 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::process::Output; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepositorySummary { - pub work_dir: PathBuf, - pub git_dir: PathBuf, - pub branch: Option, - pub branch_sync: BranchSyncStatus, - pub changes: usize, - pub insertions: usize, - pub deletions: usize, - pub files: Vec, - pub latest_commit: Option, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct BranchSyncStatus { - pub ahead: usize, - pub behind: usize, -} - -impl BranchSyncStatus { - pub fn is_synced(self) -> bool { - self.ahead == 0 && self.behind == 0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitInfo { - pub short_sha: String, - pub subject: String, - pub author: String, - pub relative_time: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileChange { - pub path: String, - pub kind: FileChangeKind, - pub staged: bool, - pub insertions: usize, - pub deletions: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileChangeKind { - Created, - Modified, - Deleted, - Renamed, - Conflicted, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepositoryDiff { - pub work_dir: PathBuf, - pub files: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileDiff { - pub old_path: Option, - pub path: String, - pub kind: FileChangeKind, - pub binary: bool, - pub hunks: Vec, - pub conflicts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiffHunk { - pub old_start: usize, - pub old_lines: usize, - pub new_start: usize, - pub new_lines: usize, - pub header: String, - pub lines: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiffLine { - pub kind: DiffLineKind, - pub old_line: Option, - pub new_line: Option, - pub text: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiffLineKind { - Context, - Added, - Removed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConflictBlock { - pub start_line: usize, - pub separator_line: usize, - pub end_line: usize, - pub current_label: String, - pub incoming_label: String, - pub current: Vec, - pub incoming: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConflictLine { - pub line: usize, - pub text: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConflictResolution { - Current, - Incoming, - Both, -} - -#[derive(Debug, Clone)] -pub struct Remote { - pub name: String, - pub url: String, -} - -#[derive(Debug, Clone)] -pub struct Tag { - pub name: String, - pub message: String, -} - -#[derive(Debug, Clone)] -pub struct Stash { - pub id: String, - pub message: String, - pub files: Vec, -} - -#[derive(Debug, Clone)] -pub struct StashFile { - pub path: String, - pub insertions: usize, - pub deletions: usize, -} - -#[derive(Debug, Clone)] -pub struct Branch { - pub name: String, - pub current: bool, - pub remote: bool, -} - -impl RepositorySummary { - pub fn discover(path: impl AsRef) -> Result { - let repo = gix::discover(path.as_ref()).map_err(|source| Error::Discover { - path: path.as_ref().to_path_buf(), - source: source.to_string(), - })?; - - let branch = repo - .head_name() - .map_err(|source| Error::ReadHead(source.to_string()))? - .map(|name| name.shorten().to_string()); - - let files = file_changes(path.as_ref()).unwrap_or_default(); - let changes = files.len(); - let stats = files.iter().fold(DiffStats::default(), |mut stats, file| { - stats.insertions += file.insertions; - stats.deletions += file.deletions; - stats - }); - let branch_sync = read_branch_sync_status(path.as_ref()).unwrap_or_default(); - let latest_commit = read_latest_commit(path.as_ref()).ok().flatten(); - - Ok(Self { - work_dir: repo - .workdir() - .map(Path::to_path_buf) - .unwrap_or_else(|| repo.path().to_path_buf()), - git_dir: repo.path().to_path_buf(), - branch, - branch_sync, - changes, - insertions: stats.insertions, - deletions: stats.deletions, - files, - latest_commit, - }) - } - - pub fn is_clean(&self) -> bool { - self.changes == 0 - } -} - -impl RepositoryDiff { - pub fn discover(path: impl AsRef) -> Result { - Self::discover_pathspec(path.as_ref(), None) - } - - pub fn discover_paths(path: impl AsRef, paths: &[String]) -> Result { - if paths.is_empty() { - let work_dir = git_work_dir(path.as_ref())?; - return Ok(Self { - work_dir, - files: Vec::new(), - }); - } - - Self::discover_pathspec(path.as_ref(), Some(paths)) - } - - fn discover_pathspec(path: &Path, paths: Option<&[String]>) -> Result { - let work_dir = git_work_dir(path.as_ref())?; - let has_head = git_output(&work_dir, &["rev-parse", "--verify", "HEAD"]).is_ok(); - let output = if has_head { - let mut args = vec![ - "diff", - "--no-color", - "--no-ext-diff", - "--find-renames", - "--unified=3", - "HEAD", - "--", - ]; - if let Some(paths) = paths { - args.extend(paths.iter().map(String::as_str)); - } - git_output(&work_dir, &args)? - } else { - String::new() - }; - let mut files = parse_unified_diff(&output); - let changes = filtered_file_changes(&work_dir, paths); - apply_status_to_file_diffs(&mut files, &work_dir, &changes); - synthesize_created_file_diffs(&mut files, &work_dir, &changes); - synthesize_conflicted_file_diffs(&mut files, &work_dir, &changes); - - Ok(Self { work_dir, files }) - } - - pub fn is_empty(&self) -> bool { - self.files.is_empty() - } -} - -fn filtered_file_changes(path: &Path, paths: Option<&[String]>) -> Vec { - let changes = file_changes(path).unwrap_or_default(); - let Some(paths) = paths else { - return changes; - }; - - let paths = paths.iter().map(String::as_str).collect::>(); - changes - .into_iter() - .filter(|change| paths.contains(change.path.as_str())) - .collect() -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct DiffStats { - insertions: usize, - deletions: usize, -} - -fn file_changes(path: &Path) -> Result, Error> { - let work_dir = git_work_dir(path)?; - let status = git_output( - &work_dir, - &["status", "--porcelain=v1", "-z", "--untracked-files=all"], - )?; - let mut stats_by_path = git_output(&work_dir, &["diff", "--numstat", "HEAD"]) - .map(|numstat| parse_numstat(&numstat)) - .unwrap_or_default(); - Ok(parse_status_changes(&status, &work_dir, &mut stats_by_path)) -} - -struct ParsedHunk { - hunk: DiffHunk, - old_line: usize, - new_line: usize, -} - -fn parse_unified_diff(output: &str) -> Vec { - let mut files = Vec::new(); - let mut current_file: Option = None; - let mut current_hunk: Option = None; - - for line in output.lines() { - if line.starts_with("diff --git ") { - finish_diff_file(&mut files, &mut current_file, &mut current_hunk); - current_file = Some(parse_diff_git_header(line)); - continue; - } - - let Some(file) = current_file.as_mut() else { - continue; - }; - - if let Some((old_start, old_lines, new_start, new_lines, header)) = parse_hunk_header(line) - { - finish_hunk(file, &mut current_hunk); - current_hunk = Some(ParsedHunk { - hunk: DiffHunk { - old_start, - old_lines, - new_start, - new_lines, - header, - lines: Vec::new(), - }, - old_line: old_start, - new_line: new_start, - }); - continue; - } - - if let Some(hunk) = current_hunk.as_mut() { - if parse_diff_line(line, hunk) { - continue; - } - } - - parse_file_header_line(file, line); - } - - finish_diff_file(&mut files, &mut current_file, &mut current_hunk); - files -} - -fn finish_diff_file( - files: &mut Vec, - current_file: &mut Option, - current_hunk: &mut Option, -) { - if let Some(file) = current_file.as_mut() { - finish_hunk(file, current_hunk); - } - if let Some(file) = current_file.take() { - if file.binary || !file.hunks.is_empty() { - files.push(file); - } - } -} - -fn finish_hunk(file: &mut FileDiff, current_hunk: &mut Option) { - if let Some(parsed) = current_hunk.take() - && !parsed.hunk.lines.is_empty() - { - file.hunks.push(parsed.hunk); - } -} - -fn parse_diff_git_header(line: &str) -> FileDiff { - let rest = line.trim_start_matches("diff --git "); - let (old_path, path) = rest - .split_once(" b/") - .map(|(old, new)| { - ( - normalize_diff_path(old), - normalize_diff_path(&format!("b/{new}")), - ) - }) - .unwrap_or_else(|| (String::new(), normalize_diff_path(rest))); - - FileDiff { - old_path: (old_path != path).then_some(old_path), - path, - kind: FileChangeKind::Modified, - binary: false, - hunks: Vec::new(), - conflicts: Vec::new(), - } -} - -fn normalize_diff_path(path: &str) -> String { - path.trim() - .trim_matches('"') - .strip_prefix("a/") - .or_else(|| path.trim().trim_matches('"').strip_prefix("b/")) - .unwrap_or_else(|| path.trim().trim_matches('"')) - .to_string() -} - -fn parse_file_header_line(file: &mut FileDiff, line: &str) { - if line.starts_with("new file mode ") { - file.kind = FileChangeKind::Created; - } else if line.starts_with("deleted file mode ") { - file.kind = FileChangeKind::Deleted; - } else if let Some(path) = line.strip_prefix("rename from ") { - file.kind = FileChangeKind::Renamed; - file.old_path = Some(normalize_diff_path(path)); - } else if let Some(path) = line.strip_prefix("rename to ") { - file.kind = FileChangeKind::Renamed; - file.path = normalize_diff_path(path); - } else if let Some(path) = line.strip_prefix("--- ") { - let path = normalize_diff_path(path); - if path == "/dev/null" { - file.kind = FileChangeKind::Created; - file.old_path = None; - } else { - file.old_path = (path != file.path).then_some(path); - } - } else if let Some(path) = line.strip_prefix("+++ ") { - let path = normalize_diff_path(path); - if path == "/dev/null" { - file.kind = FileChangeKind::Deleted; - } else { - file.path = path; - } - } else if line.starts_with("Binary files ") { - file.binary = true; - } -} - -fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize, String)> { - let rest = line.strip_prefix("@@ ")?; - let (ranges, _) = rest.split_once(" @@")?; - let mut parts = ranges.split_whitespace(); - let old = parts.next()?.strip_prefix('-')?; - let new = parts.next()?.strip_prefix('+')?; - let (old_start, old_lines) = parse_hunk_range(old)?; - let (new_start, new_lines) = parse_hunk_range(new)?; - Some((old_start, old_lines, new_start, new_lines, line.to_string())) -} - -fn parse_hunk_range(range: &str) -> Option<(usize, usize)> { - let (start, lines) = range.split_once(',').unwrap_or((range, "1")); - Some((start.parse().ok()?, lines.parse().ok()?)) -} - -fn parse_diff_line(line: &str, hunk: &mut ParsedHunk) -> bool { - let Some(prefix) = line.chars().next() else { - return false; - }; - let text = line.get(1..).unwrap_or_default().to_string(); - match prefix { - ' ' => { - hunk.hunk.lines.push(DiffLine { - kind: DiffLineKind::Context, - old_line: Some(hunk.old_line), - new_line: Some(hunk.new_line), - text, - }); - hunk.old_line += 1; - hunk.new_line += 1; - true - } - '+' => { - hunk.hunk.lines.push(DiffLine { - kind: DiffLineKind::Added, - old_line: None, - new_line: Some(hunk.new_line), - text, - }); - hunk.new_line += 1; - true - } - '-' => { - hunk.hunk.lines.push(DiffLine { - kind: DiffLineKind::Removed, - old_line: Some(hunk.old_line), - new_line: None, - text, - }); - hunk.old_line += 1; - true - } - '\\' => true, - _ => false, - } -} - -fn apply_status_to_file_diffs(files: &mut [FileDiff], work_dir: &Path, changes: &[FileChange]) { - for file in files { - let Some(change) = changes.iter().find(|change| { - change.path == file.path || file.old_path.as_deref() == Some(&change.path) - }) else { - continue; - }; - - file.kind = change.kind; - if change.kind == FileChangeKind::Conflicted { - file.conflicts = read_conflicts(work_dir.join(&file.path)); - } - } -} - -fn synthesize_conflicted_file_diffs( - files: &mut Vec, - work_dir: &Path, - changes: &[FileChange], -) { - let existing_paths = files - .iter() - .flat_map(|file| file.old_path.iter().chain(std::iter::once(&file.path))) - .cloned() - .collect::>(); - - for change in changes { - if change.kind != FileChangeKind::Conflicted || existing_paths.contains(&change.path) { - continue; - } - files.push(synthesize_conflicted_file_diff(work_dir, &change.path)); - } -} - -fn synthesize_conflicted_file_diff(work_dir: &Path, path: &str) -> FileDiff { - let conflicts = read_conflicts(work_dir.join(path)); - FileDiff { - old_path: None, - path: path.to_string(), - kind: FileChangeKind::Conflicted, - binary: conflicts.is_empty(), - hunks: Vec::new(), - conflicts, - } -} - -fn read_conflicts(path: impl AsRef) -> Vec { - std::fs::read_to_string(path) - .map(|content| parse_conflicts(&content)) - .unwrap_or_default() -} - -fn parse_conflicts(content: &str) -> Vec { - enum ParsedConflict { - Current { - start_line: usize, - current_label: String, - current: Vec, - }, - Incoming { - start_line: usize, - separator_line: usize, - current_label: String, - current: Vec, - incoming: Vec, - }, - } - - let mut conflicts = Vec::new(); - let mut current_conflict: Option = None; - - for (ix, line) in content.lines().enumerate() { - let line_number = ix + 1; - if let Some(label) = conflict_marker_label(line, "<<<<<<<") { - current_conflict = Some(ParsedConflict::Current { - start_line: line_number, - current_label: label, - current: Vec::new(), - }); - continue; - } - - if line.starts_with("=======") { - if let Some(ParsedConflict::Current { - start_line, - current_label, - current, - }) = current_conflict.take() - { - current_conflict = Some(ParsedConflict::Incoming { - start_line, - separator_line: line_number, - current_label, - current, - incoming: Vec::new(), - }); - } - continue; - } - - if let Some(label) = conflict_marker_label(line, ">>>>>>>") { - if let Some(ParsedConflict::Incoming { - start_line, - separator_line, - current_label, - current, - incoming, - }) = current_conflict.take() - { - conflicts.push(ConflictBlock { - start_line, - separator_line, - end_line: line_number, - current_label, - incoming_label: label, - current, - incoming, - }); - } - continue; - } - - match current_conflict.as_mut() { - Some(ParsedConflict::Current { current, .. }) => current.push(ConflictLine { - line: line_number, - text: line.to_string(), - }), - Some(ParsedConflict::Incoming { incoming, .. }) => incoming.push(ConflictLine { - line: line_number, - text: line.to_string(), - }), - None => {} - } - } - - conflicts -} - -fn conflict_marker_label(line: &str, marker: &str) -> Option { - line.strip_prefix(marker) - .map(str::trim) - .map(ToString::to_string) -} - -pub fn resolve_conflict_content( - content: &str, - start_line: usize, - resolution: ConflictResolution, -) -> Option { - let conflicts = parse_conflicts(content); - let conflict = conflicts - .into_iter() - .find(|conflict| conflict.start_line == start_line)?; - let mut lines = content.lines().map(ToString::to_string).collect::>(); - let replacement = match resolution { - ConflictResolution::Current => conflict.current, - ConflictResolution::Incoming => conflict.incoming, - ConflictResolution::Both => conflict - .current - .into_iter() - .chain(conflict.incoming) - .collect::>(), - }; - let replacement = replacement - .into_iter() - .map(|line| line.text) - .collect::>(); - - lines.splice(conflict.start_line - 1..conflict.end_line, replacement); - let mut resolved = lines.join("\n"); - if content.ends_with('\n') { - resolved.push('\n'); - } - Some(resolved) -} - -fn synthesize_created_file_diffs( - files: &mut Vec, - work_dir: &Path, - changes: &[FileChange], -) { - let existing_paths = files - .iter() - .flat_map(|file| file.old_path.iter().chain(std::iter::once(&file.path))) - .cloned() - .collect::>(); - - for change in changes { - if change.kind != FileChangeKind::Created || existing_paths.contains(&change.path) { - continue; - } - files.push(synthesize_created_file_diff(work_dir, &change.path)); - } -} - -fn synthesize_created_file_diff(work_dir: &Path, path: &str) -> FileDiff { - let Ok(content) = std::fs::read_to_string(work_dir.join(path)) else { - return FileDiff { - old_path: None, - path: path.to_string(), - kind: FileChangeKind::Created, - binary: true, - hunks: Vec::new(), - conflicts: Vec::new(), - }; - }; - - let lines = content.lines().collect::>(); - let diff_lines = lines - .iter() - .enumerate() - .map(|(ix, line)| DiffLine { - kind: DiffLineKind::Added, - old_line: None, - new_line: Some(ix + 1), - text: (*line).to_string(), - }) - .collect::>(); - let hunks = if diff_lines.is_empty() { - Vec::new() - } else { - vec![DiffHunk { - old_start: 0, - old_lines: 0, - new_start: 1, - new_lines: diff_lines.len(), - header: format!("@@ -0,0 +1,{} @@", diff_lines.len()), - lines: diff_lines, - }] - }; - - FileDiff { - old_path: None, - path: path.to_string(), - kind: FileChangeKind::Created, - binary: false, - hunks, - conflicts: Vec::new(), - } -} - -fn parse_numstat(output: &str) -> HashMap { - output.lines().filter_map(parse_numstat_line).collect() -} - -fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> { - let mut parts = line.split('\t'); - let insertions = parts.next().unwrap_or_default(); - let deletions = parts.next().unwrap_or_default(); - let changed_path = parts.next_back().filter(|path| !path.is_empty())?; - Some(( - normalize_numstat_path(changed_path), - DiffStats { - insertions: insertions.parse::().unwrap_or(0), - deletions: deletions.parse::().unwrap_or(0), - }, - )) -} - -struct StatusEntry { - index_status: char, - worktree_status: char, - path: String, -} - -fn parse_status_changes( - status: &str, - work_dir: &Path, - stats_by_path: &mut HashMap, -) -> Vec { - let mut changes = Vec::new(); - let mut entries = status.split('\0').filter(|entry| !entry.is_empty()); - while let Some(raw_entry) = entries.next() { - let Some(entry) = parse_status_entry(raw_entry) else { - continue; - }; - if matches!(entry.index_status, 'R' | 'C') { - let _ = entries.next(); - } - - let kind = change_kind(entry.index_status, entry.worktree_status); - let staged = kind != FileChangeKind::Conflicted - && entry.index_status != ' ' - && entry.index_status != '?'; - let mut stats = stats_by_path.remove(&entry.path).unwrap_or_default(); - if kind == FileChangeKind::Created && stats.insertions == 0 && stats.deletions == 0 { - stats.insertions = count_text_lines(work_dir.join(&entry.path)); - } - changes.push(FileChange { - path: entry.path, - kind, - staged, - insertions: stats.insertions, - deletions: stats.deletions, - }); - } - changes -} - -fn parse_status_entry(entry: &str) -> Option { - if entry.len() < 4 { - return None; - } - let bytes = entry.as_bytes(); - Some(StatusEntry { - index_status: bytes[0] as char, - worktree_status: bytes[1] as char, - path: entry[3..].to_string(), - }) -} - -fn change_kind(index_status: char, worktree_status: char) -> FileChangeKind { - match (index_status, worktree_status) { - ('D', 'D') - | ('A', 'U') - | ('U', 'D') - | ('U', 'A') - | ('D', 'U') - | ('A', 'A') - | ('U', 'U') => FileChangeKind::Conflicted, - ('R', _) | ('C', _) => FileChangeKind::Renamed, - ('A', _) | ('?', '?') => FileChangeKind::Created, - ('D', _) | (_, 'D') => FileChangeKind::Deleted, - _ => FileChangeKind::Modified, - } -} - -fn normalize_numstat_path(path: &str) -> String { - path.rsplit_once(" => ") - .map(|(_, after)| after.trim_matches(['{', '}']).to_string()) - .unwrap_or_else(|| path.to_string()) -} - -fn read_branch_sync_status(path: &Path) -> Result { - let output = git_output( - path, - &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], - )?; - Ok(parse_branch_sync_status(&output).unwrap_or_default()) -} - -fn parse_branch_sync_status(output: &str) -> Option { - let mut parts = output.split_whitespace(); - let ahead = parts.next()?.parse().ok()?; - let behind = parts.next()?.parse().ok()?; - Some(BranchSyncStatus { ahead, behind }) -} - -fn read_latest_commit(path: &Path) -> Result, Error> { - let output = git_output( - path, - &["log", "-1", "--pretty=format:%h%x1f%s%x1f%an%x1f%ar"], - )?; - let trimmed = output.trim(); - if trimmed.is_empty() { - return Ok(None); - } - let mut parts = trimmed.splitn(4, '\x1f'); - let Some(short_sha) = parts.next().filter(|short_sha| !short_sha.is_empty()) else { - return Ok(None); - }; - let subject = parts.next().unwrap_or("").to_string(); - let author = parts.next().unwrap_or("").to_string(); - let relative_time = parts.next().unwrap_or("").to_string(); - Ok(Some(CommitInfo { - short_sha: short_sha.to_string(), - subject, - author, - relative_time, - })) -} - -fn count_text_lines(path: impl AsRef) -> usize { - std::fs::read_to_string(path) - .map(|contents| contents.lines().count()) - .unwrap_or(0) -} - -pub fn stage_all(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["add", "--all"]) -} - -pub fn init(path: impl AsRef) -> Result<(), Error> { - let output = std::process::Command::new("git") - .args(["init"]) - .current_dir(path.as_ref()) - .output() - .map_err(|source| Error::Status(source.to_string()))?; - if output.status.success() { - Ok(()) - } else { - Err(Error::Status( - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )) - } -} - -pub fn unstage_all(path: impl AsRef) -> Result<(), Error> { - unstage_matching_changes(path.as_ref(), None) -} - -pub fn stage_file(path: impl AsRef, file: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["add", "--all", "--", file]) -} - -pub fn stage_files(path: impl AsRef, files: &[String]) -> Result<(), Error> { - let path = path.as_ref(); - for file in files { - run_git(path, &["add", "--all", "--", file])?; - } - Ok(()) -} - -pub fn unstage_file(path: impl AsRef, file: &str) -> Result<(), Error> { - unstage_matching_changes(path.as_ref(), Some(file)) -} - -fn unstage_matching_changes(path: &Path, selected_path: Option<&str>) -> Result<(), Error> { - for change_path in non_conflicted_change_paths(path, selected_path, Some(true))? { - run_git(path, &["reset", "--", &change_path])?; - } - Ok(()) -} - -fn non_conflicted_change_paths( - path: &Path, - selected_path: Option<&str>, - staged: Option, -) -> Result, Error> { - Ok(file_changes(path)? - .into_iter() - .filter(|change| change.kind != FileChangeKind::Conflicted) - .filter(|change| selected_path.is_none_or(|path| path_matches_change(path, &change.path))) - .filter(|change| staged.is_none_or(|staged| change.staged == staged)) - .map(|change| change.path) - .collect()) -} - -fn path_matches_change(selected_path: &str, change_path: &str) -> bool { - let selected_path = selected_path.trim_end_matches('/'); - change_path == selected_path - || change_path - .strip_prefix(selected_path) - .is_some_and(|suffix| suffix.starts_with('/')) -} - -pub fn stash_staged(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["stash", "push", "--staged"]) -} - -pub fn commit_staged(path: impl AsRef, message: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["commit", "-m", message]) -} - -pub fn list_branches(path: impl AsRef) -> Result, Error> { - let local_output = git_output( - path.as_ref(), - &["branch", "--format=%(HEAD)%09%(refname:short)"], - )?; - let remote_output = git_output( - path.as_ref(), - &[ - "branch", - "--remotes", - "--format=%(refname:short)%09%(symref)", - ], - )?; - let mut branches = local_output - .lines() - .filter_map(parse_local_branch) - .collect::>(); - - branches.extend(remote_output.lines().filter_map(parse_remote_branch)); - - Ok(branches) -} - -fn parse_local_branch(line: &str) -> Option { - let (head, name) = line.split_once('\t')?; - let name = name.trim(); - if name.is_empty() { - return None; - } - Some(Branch { - name: name.to_string(), - current: head.trim() == "*", - remote: false, - }) -} - -fn parse_remote_branch(line: &str) -> Option { - let (name, symref) = line.split_once('\t').unwrap_or((line, "")); - let name = name.trim(); - if name.is_empty() || !symref.trim().is_empty() { - return None; - } - Some(Branch { - name: name.to_string(), - current: false, - remote: true, - }) -} - -pub fn switch_branch(path: impl AsRef, branch: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["switch", branch]) -} - -pub fn switch_remote_branch(path: impl AsRef, branch: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["switch", "--track", branch]) -} - -pub fn create_branch(path: impl AsRef, branch: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["branch", branch]) -} - -pub fn delete_branch(path: impl AsRef, branch: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["branch", "-d", branch]) -} - -pub fn push(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["push"]) -} - -pub fn force_push(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["push", "--force-with-lease"]) -} - -pub fn pull(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["pull"]) -} - -pub fn pull_rebase(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["pull", "--rebase"]) -} - -pub fn fetch(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["fetch", "--all", "--prune"]) -} - -pub fn discard_all_changes(path: impl AsRef) -> Result<(), Error> { - run_git(path.as_ref(), &["reset", "--hard", "HEAD"])?; - run_git(path.as_ref(), &["clean", "-fd"]) -} - -pub fn discard_files(path: impl AsRef, files: &[String]) -> Result<(), Error> { - for file in files { - run_git( - path.as_ref(), - &["restore", "--staged", "--worktree", "--", file], - )?; - } - Ok(()) -} - -pub fn list_remotes(path: impl AsRef) -> Result, Error> { - let output = git_output(path.as_ref(), &["remote", "-v"])?; - Ok(output.lines().filter_map(parse_remote).collect()) -} - -fn parse_remote(line: &str) -> Option { - if !line.ends_with("(fetch)") { - return None; - } - let mut parts = line.split_whitespace(); - let name = parts.next()?; - let url = parts.next()?; - Some(Remote { - name: name.to_string(), - url: url.to_string(), - }) -} - -pub fn add_remote(path: impl AsRef, name: &str, url: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["remote", "add", name, url]) -} - -pub fn delete_remote(path: impl AsRef, name: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["remote", "remove", name]) -} - -pub fn list_tags(path: impl AsRef) -> Result, Error> { - let output = git_output(path.as_ref(), &["tag", "-n99"])?; - Ok(output.lines().filter_map(parse_tag).collect()) -} - -fn parse_tag(line: &str) -> Option { - let mut parts = line.splitn(2, char::is_whitespace); - let name = parts.next()?.trim(); - if name.is_empty() { - return None; - } - Some(Tag { - name: name.to_string(), - message: parts.next().unwrap_or_default().trim().to_string(), - }) -} - -pub fn add_tag( - path: impl AsRef, - name: &str, - message: Option<&str>, - commit_sha: Option<&str>, -) -> Result<(), Error> { - let mut args = vec!["tag"]; - if let Some(message) = message.filter(|message| !message.trim().is_empty()) { - args.extend(["-a", name, "-m", message]); - } else { - args.push(name); - } - if let Some(commit_sha) = commit_sha.filter(|sha| !sha.trim().is_empty()) { - args.push(commit_sha); - } - run_git(path.as_ref(), &args) -} - -pub fn delete_tag(path: impl AsRef, name: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["tag", "-d", name]) -} - -pub fn list_stashes(path: impl AsRef) -> Result, Error> { - let path = path.as_ref(); - let output = git_output(path, &["stash", "list"])?; - output - .lines() - .filter_map(parse_stash_line) - .map(|(stash_id, message)| { - let files = parse_stash_files(&git_output( - path, - &[ - "stash", - "show", - "--include-untracked", - "--numstat", - &stash_id, - ], - )?); - Ok(Stash { - id: stash_id, - message, - files, - }) - }) - .collect() -} - -fn parse_stash_line(line: &str) -> Option<(String, String)> { - let (stash_id, message) = line.split_once(':')?; - Some((stash_id.to_string(), message.trim().to_string())) -} - -fn parse_stash_files(output: &str) -> Vec { - output - .lines() - .filter_map(parse_numstat_line) - .map(|(path, stats)| StashFile { - path, - insertions: stats.insertions, - deletions: stats.deletions, - }) - .collect() -} - -pub fn apply_stash(path: impl AsRef, id: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["stash", "apply", id]) -} - -pub fn delete_stash(path: impl AsRef, id: &str) -> Result<(), Error> { - run_git(path.as_ref(), &["stash", "drop", id]) -} - -fn git_output(path: &Path, args: &[&str]) -> Result { - let output = run_git_command(path, args, false)?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn run_git(path: &Path, args: &[&str]) -> Result<(), Error> { - run_git_command(path, args, true).map(|_| ()) -} - -fn run_git_command( - path: &Path, - args: &[&str], - allow_optional_locks: bool, -) -> Result { - let work_dir = git_work_dir(path)?; - let mut command = std::process::Command::new("git"); - command.args(args).current_dir(work_dir); - if !allow_optional_locks { - // Background reads must not create .git/index.lock while a write is starting. - command.env("GIT_OPTIONAL_LOCKS", "0"); - } - - let output = command - .output() - .map_err(|source| Error::Status(source.to_string()))?; - if output.status.success() { - Ok(output) - } else { - Err(Error::Status( - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )) - } -} - -fn git_work_dir(path: &Path) -> Result { - let repo = gix::discover(path).map_err(|source| Error::Discover { - path: path.to_path_buf(), - source: source.to_string(), - })?; - Ok(repo - .workdir() - .map(Path::to_path_buf) - .unwrap_or_else(|| path.to_path_buf())) -} - -#[derive(Debug, Clone)] -pub enum Error { - Discover { path: PathBuf, source: String }, - ReadHead(String), - Status(String), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Discover { path, source } => { - write!( - f, - "failed to discover git repository at {}: {source}", - path.display() - ) - } - Self::ReadHead(source) => write!(f, "failed to read git HEAD: {source}"), - Self::Status(source) => write!(f, "failed to read git status: {source}"), - } - } -} - -impl std::error::Error for Error {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_numstat_by_normalized_path() { - let stats = parse_numstat("3\t1\tsrc/lib.rs\n-\t-\tassets/logo.png\n2\t0\told => new.rs\n"); - - assert_eq!( - stats.get("src/lib.rs"), - Some(&DiffStats { - insertions: 3, - deletions: 1, - }) - ); - assert_eq!( - stats.get("assets/logo.png"), - Some(&DiffStats { - insertions: 0, - deletions: 0, - }) - ); - assert!(stats.contains_key("new.rs")); - } - - #[test] - fn parses_unified_diff_hunks() { - let files = parse_unified_diff(concat!( - "diff --git a/src/lib.rs b/src/lib.rs\n", - "index 111..222 100644\n", - "--- a/src/lib.rs\n", - "+++ b/src/lib.rs\n", - "@@ -1,3 +1,4 @@\n", - " one\n", - "-two\n", - "+two changed\n", - "+three\n", - " four\n", - )); - - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "src/lib.rs"); - assert_eq!(files[0].kind, FileChangeKind::Modified); - assert_eq!(files[0].hunks.len(), 1); - assert_eq!(files[0].hunks[0].old_start, 1); - assert_eq!(files[0].hunks[0].new_start, 1); - assert_eq!(files[0].hunks[0].lines.len(), 5); - assert_eq!(files[0].hunks[0].lines[1].kind, DiffLineKind::Removed); - assert_eq!(files[0].hunks[0].lines[1].old_line, Some(2)); - assert_eq!(files[0].hunks[0].lines[2].kind, DiffLineKind::Added); - assert_eq!(files[0].hunks[0].lines[2].new_line, Some(2)); - } - - #[test] - fn parses_renamed_diff_paths() { - let files = parse_unified_diff(concat!( - "diff --git a/old.rs b/new.rs\n", - "similarity index 90%\n", - "rename from old.rs\n", - "rename to new.rs\n", - "--- a/old.rs\n", - "+++ b/new.rs\n", - "@@ -1 +1 @@\n", - "-old\n", - "+new\n", - )); - - assert_eq!(files.len(), 1); - assert_eq!(files[0].kind, FileChangeKind::Renamed); - assert_eq!(files[0].old_path.as_deref(), Some("old.rs")); - assert_eq!(files[0].path, "new.rs"); - } - - #[test] - fn parses_stash_files_with_stats() { - let files = - parse_stash_files("3\t1\tsrc/lib.rs\n-\t-\tassets/logo.png\n2\t0\told => new.rs\n"); - - assert_eq!(files.len(), 3); - assert_eq!(files[0].path, "src/lib.rs"); - assert_eq!(files[0].insertions, 3); - assert_eq!(files[0].deletions, 1); - assert_eq!(files[1].path, "assets/logo.png"); - assert_eq!(files[1].insertions, 0); - assert_eq!(files[1].deletions, 0); - assert_eq!(files[2].path, "new.rs"); - assert_eq!(files[2].insertions, 2); - assert_eq!(files[2].deletions, 0); - } - - #[test] - fn parses_branch_sync_status() { - assert_eq!( - parse_branch_sync_status("2\t3\n"), - Some(BranchSyncStatus { - ahead: 2, - behind: 3, - }) - ); - assert_eq!(parse_branch_sync_status("bad\t3\n"), None); - } - - #[test] - fn parses_status_entries_with_stats_and_renames() { - let mut stats = HashMap::from([( - "src/lib.rs".to_string(), - DiffStats { - insertions: 4, - deletions: 2, - }, - )]); - let changes = parse_status_changes( - " M src/lib.rs\0?? scratch.txt\0R renamed.rs\0old.rs\0bad\0", - Path::new("/tmp"), - &mut stats, - ); - - assert_eq!(changes.len(), 3); - assert_eq!(changes[0].path, "src/lib.rs"); - assert_eq!(changes[0].kind, FileChangeKind::Modified); - assert!(!changes[0].staged); - assert_eq!(changes[0].insertions, 4); - assert_eq!(changes[0].deletions, 2); - - assert_eq!(changes[1].kind, FileChangeKind::Created); - assert!(!changes[1].staged); - - assert_eq!(changes[2].path, "renamed.rs"); - assert_eq!(changes[2].kind, FileChangeKind::Renamed); - assert!(changes[2].staged); - } - - #[test] - fn parses_conflicted_status_entries() { - let mut stats = HashMap::from([( - "file.txt".to_string(), - DiffStats { - insertions: 4, - deletions: 0, - }, - )]); - let changes = parse_status_changes( - "UU file.txt\0AA both-added.txt\0DD both-deleted.txt\0", - Path::new("/tmp"), - &mut stats, - ); - - assert_eq!(changes.len(), 3); - assert_eq!(changes[0].path, "file.txt"); - assert_eq!(changes[0].kind, FileChangeKind::Conflicted); - assert!(!changes[0].staged); - assert_eq!(changes[0].insertions, 4); - - assert_eq!(changes[1].kind, FileChangeKind::Conflicted); - assert!(!changes[1].staged); - assert_eq!(changes[2].kind, FileChangeKind::Conflicted); - assert!(!changes[2].staged); - } - - #[test] - fn parses_conflict_blocks() { - let conflicts = parse_conflicts(concat!( - "before\n", - "<<<<<<< HEAD\n", - "current one\n", - "current two\n", - "=======\n", - "incoming one\n", - ">>>>>>> feature/auth\n", - "after\n", - )); - - assert_eq!(conflicts.len(), 1); - assert_eq!(conflicts[0].start_line, 2); - assert_eq!(conflicts[0].separator_line, 5); - assert_eq!(conflicts[0].end_line, 7); - assert_eq!(conflicts[0].current_label, "HEAD"); - assert_eq!(conflicts[0].incoming_label, "feature/auth"); - assert_eq!(conflicts[0].current[0].line, 3); - assert_eq!(conflicts[0].current[0].text, "current one"); - assert_eq!(conflicts[0].incoming[0].line, 6); - assert_eq!(conflicts[0].incoming[0].text, "incoming one"); - } - - #[test] - fn resolves_conflict_content() { - let content = concat!( - "before\n", - "<<<<<<< HEAD\n", - "current\n", - "=======\n", - "incoming\n", - ">>>>>>> feature\n", - "after\n", - ); - - assert_eq!( - resolve_conflict_content(content, 2, ConflictResolution::Current).as_deref(), - Some("before\ncurrent\nafter\n"), - ); - assert_eq!( - resolve_conflict_content(content, 2, ConflictResolution::Incoming).as_deref(), - Some("before\nincoming\nafter\n"), - ); - assert_eq!( - resolve_conflict_content(content, 2, ConflictResolution::Both).as_deref(), - Some("before\ncurrent\nincoming\nafter\n"), - ); - } - - #[test] - fn discovers_files_in_unborn_repository() { - let root = temp_test_dir("unborn"); - std::fs::create_dir_all(&root).expect("create test repository directory"); - run_test_git(&root, &["init"]); - std::fs::write(root.join("file.txt"), "first\nsecond\n").expect("write test file"); - std::fs::create_dir(root.join("src")).expect("create nested source directory"); - std::fs::write(root.join("src/main.zig"), "pub fn main() void {}\n") - .expect("write nested test file"); - - let summary = RepositorySummary::discover(&root).expect("discover unborn repository"); - - assert_eq!(summary.changes, 2); - assert_eq!(summary.files.len(), 2); - assert!(summary.files.iter().all(|file| !file.path.ends_with('/'))); - let file = summary - .files - .iter() - .find(|file| file.path == "file.txt") - .expect("include root file"); - assert_eq!(file.kind, FileChangeKind::Created); - assert_eq!(file.insertions, 2); - let nested_file = summary - .files - .iter() - .find(|file| file.path == "src/main.zig") - .expect("include nested file instead of collapsed directory"); - assert_eq!(nested_file.kind, FileChangeKind::Created); - assert_eq!(nested_file.insertions, 1); - - run_test_git(&root, &["add", "src/main.zig"]); - let staged_summary = - RepositorySummary::discover(&root).expect("rediscover unborn repository"); - let staged_nested_file = staged_summary - .files - .iter() - .find(|file| file.path == "src/main.zig") - .expect("include staged nested file"); - assert!(staged_nested_file.staged); - assert_eq!(staged_nested_file.insertions, 1); - - std::fs::remove_dir_all(root).expect("remove test repository directory"); - } - - #[test] - fn unstages_files_in_unborn_repository() { - let root = temp_test_dir("unstage-unborn"); - std::fs::create_dir_all(root.join("src")).expect("create test repository directories"); - run_test_git(&root, &["init"]); - std::fs::write(root.join("file.txt"), "root\n").expect("write root test file"); - std::fs::write(root.join("src/main.zig"), "pub fn main() void {}\n") - .expect("write nested test file"); - run_test_git(&root, &["add", "."]); - - unstage_file(&root, "src").expect("unstage nested directory before first commit"); - let summary = RepositorySummary::discover(&root).expect("discover partially unstaged repo"); - assert!( - summary - .files - .iter() - .find(|file| file.path == "file.txt") - .expect("include root file") - .staged - ); - assert!( - !summary - .files - .iter() - .find(|file| file.path == "src/main.zig") - .expect("include nested file") - .staged - ); - - unstage_all(&root).expect("unstage all before first commit"); - let summary = RepositorySummary::discover(&root).expect("discover unstaged repo"); - assert!(summary.files.iter().all(|file| !file.staged)); - - std::fs::remove_dir_all(root).expect("remove test repository directory"); - } - - #[test] - fn unstage_all_does_not_clear_conflicts() { - let root = create_conflicted_test_repository("bulk-stage-conflict"); - - unstage_all(&root).expect("skip conflicted file when unstaging all"); - - let summary = RepositorySummary::discover(&root).expect("discover conflicted repository"); - assert_eq!(summary.files.len(), 1); - assert_eq!(summary.files[0].kind, FileChangeKind::Conflicted); - - std::fs::remove_dir_all(root).expect("remove test repository directory"); - } - - #[test] - fn stage_all_marks_conflict_resolved() { - let root = create_conflicted_test_repository("stage-all-conflict"); - - stage_all(&root).expect("stage all, including conflicted files"); - - let summary = RepositorySummary::discover(&root).expect("discover resolved repository"); - assert_eq!(summary.files.len(), 1); - assert_eq!(summary.files[0].kind, FileChangeKind::Modified); - assert!(summary.files[0].staged); - - std::fs::remove_dir_all(root).expect("remove test repository directory"); - } - - #[test] - fn stage_file_marks_conflict_resolved() { - let root = create_conflicted_test_repository("stage-conflict-file"); - - stage_file(&root, "file.txt").expect("stage conflicted file as resolved"); - - let summary = RepositorySummary::discover(&root).expect("discover resolved repository"); - assert_eq!(summary.files.len(), 1); - assert_eq!(summary.files[0].kind, FileChangeKind::Modified); - assert!(summary.files[0].staged); - - std::fs::remove_dir_all(root).expect("remove test repository directory"); - } - - #[test] - fn parses_git_listing_rows() { - let local = parse_local_branch("*\tmain").unwrap(); - assert_eq!(local.name, "main"); - assert!(local.current); - assert!(!local.remote); - - let remote = parse_remote_branch("origin/main\t").unwrap(); - assert_eq!(remote.name, "origin/main"); - assert!(remote.remote); - assert!(parse_remote_branch("origin/HEAD\trefs/remotes/origin/main").is_none()); - - let parsed_remote = parse_remote("origin\tgit@example.com:repo.git (fetch)").unwrap(); - assert_eq!(parsed_remote.name, "origin"); - assert_eq!(parsed_remote.url, "git@example.com:repo.git"); - - let tag = parse_tag("v1.0.0 First release").unwrap(); - assert_eq!(tag.name, "v1.0.0"); - assert_eq!(tag.message, "First release"); - - let stash = parse_stash_line("stash@{0}: WIP on main").unwrap(); - assert_eq!(stash.0, "stash@{0}"); - assert_eq!(stash.1, "WIP on main"); - } - - fn temp_test_dir(name: &str) -> PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!("kosmos-git-{name}-{}-{unique}", std::process::id())) - } - - fn create_conflicted_test_repository(name: &str) -> PathBuf { - let root = temp_test_dir(name); - std::fs::create_dir_all(&root).expect("create test repository directory"); - run_test_git(&root, &["init"]); - run_test_git(&root, &["config", "user.email", "a@example.com"]); - run_test_git(&root, &["config", "user.name", "A"]); - std::fs::write(root.join("file.txt"), "base\n").expect("write base file"); - run_test_git(&root, &["add", "file.txt"]); - run_test_git(&root, &["commit", "-m", "base"]); - run_test_git(&root, &["switch", "-c", "left"]); - std::fs::write(root.join("file.txt"), "left\n").expect("write left file"); - run_test_git(&root, &["commit", "-am", "left"]); - run_test_git(&root, &["switch", "-"]); - run_test_git(&root, &["switch", "-c", "right"]); - std::fs::write(root.join("file.txt"), "right\n").expect("write right file"); - run_test_git(&root, &["commit", "-am", "right"]); - run_test_git_expect_failure(&root, &["merge", "left"]); - root - } - - fn run_test_git(root: &Path, args: &[&str]) { - let output = std::process::Command::new("git") - .args(args) - .current_dir(root) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn run_test_git_expect_failure(root: &Path, args: &[&str]) { - let output = std::process::Command::new("git") - .args(args) - .current_dir(root) - .output() - .expect("run git command"); - assert!( - !output.status.success(), - "git {} succeeded unexpectedly", - args.join(" ") - ); - } -} diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml deleted file mode 100644 index ce6c6deb..00000000 --- a/crates/icons/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "icons" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -icondata_core = { workspace = true } -icondata_vs = { workspace = true } -image = "0.25" -resvg = "0.45" -smallvec = "1.6" -theme = { path = "../theme" } diff --git a/crates/icons/README.md b/crates/icons/README.md deleted file mode 100644 index 9c93ca4b..00000000 --- a/crates/icons/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# icons - -Role: icon assets and icon rendering primitives. - -Owns: -- Application asset registration. -- `IconName` and icon asset lookup. -- GPUI `Icon` rendering. -- File-name and language-id to icon mapping. -- SVG rasterization support used by icon rendering. - -Does Not Own: -- Deciding where icons appear in the UI. -- Tab kind registration. -- General theme definitions beyond icon rendering needs. diff --git a/crates/icons/src/assets.rs b/crates/icons/src/assets.rs deleted file mode 100644 index b46f5c1d..00000000 --- a/crates/icons/src/assets.rs +++ /dev/null @@ -1,228 +0,0 @@ -use std::borrow::Cow; - -use gpui::{AssetSource, Result, SharedString}; - -use crate::IconName; - -const KOSMOS_ICON_SVG: &[u8] = include_bytes!("../../../assets/brand/kosmos-icon.svg"); -const KOSMOS_TEXT_SVG: &[u8] = include_bytes!("../../../assets/brand/kosmos-text.svg"); -const KOSMOS_LOGO_SVG: &[u8] = include_bytes!("../../../assets/brand/kosmos-logo.svg"); - -const LANG_ASSETS: &[(&str, &[u8])] = &[ - ( - "astro.svg", - include_bytes!("../../../assets/langs/astro.svg"), - ), - ( - "astro_light.svg", - include_bytes!("../../../assets/langs/astro_light.svg"), - ), - ("bash.svg", include_bytes!("../../../assets/langs/bash.svg")), - ( - "bash_light.svg", - include_bytes!("../../../assets/langs/bash_light.svg"), - ), - ("bun.svg", include_bytes!("../../../assets/langs/bun.svg")), - ("c.svg", include_bytes!("../../../assets/langs/c.svg")), - ("cpp.svg", include_bytes!("../../../assets/langs/cpp.svg")), - ( - "csharp.svg", - include_bytes!("../../../assets/langs/csharp.svg"), - ), - ("css.svg", include_bytes!("../../../assets/langs/css.svg")), - ("dart.svg", include_bytes!("../../../assets/langs/dart.svg")), - ( - "docker.svg", - include_bytes!("../../../assets/langs/docker.svg"), - ), - ( - "dotenv.svg", - include_bytes!("../../../assets/langs/dotenv.svg"), - ), - ("git.svg", include_bytes!("../../../assets/langs/git.svg")), - ("go.svg", include_bytes!("../../../assets/langs/go.svg")), - ( - "go_light.svg", - include_bytes!("../../../assets/langs/go_light.svg"), - ), - ( - "graphql.svg", - include_bytes!("../../../assets/langs/graphql.svg"), - ), - ( - "haskell.svg", - include_bytes!("../../../assets/langs/haskell.svg"), - ), - ("html.svg", include_bytes!("../../../assets/langs/html.svg")), - ("java.svg", include_bytes!("../../../assets/langs/java.svg")), - ( - "javascript.svg", - include_bytes!("../../../assets/langs/javascript.svg"), - ), - ("json.svg", include_bytes!("../../../assets/langs/json.svg")), - ( - "julia.svg", - include_bytes!("../../../assets/langs/julia.svg"), - ), - ( - "kotlin.svg", - include_bytes!("../../../assets/langs/kotlin.svg"), - ), - ("lua.svg", include_bytes!("../../../assets/langs/lua.svg")), - ( - "markdown.svg", - include_bytes!("../../../assets/langs/markdown.svg"), - ), - ( - "markdown_light.svg", - include_bytes!("../../../assets/langs/markdown_light.svg"), - ), - ("php.svg", include_bytes!("../../../assets/langs/php.svg")), - ( - "php_light.svg", - include_bytes!("../../../assets/langs/php_light.svg"), - ), - ( - "powershell.svg", - include_bytes!("../../../assets/langs/powershell.svg"), - ), - ( - "python.svg", - include_bytes!("../../../assets/langs/python.svg"), - ), - ("r.svg", include_bytes!("../../../assets/langs/r.svg")), - ( - "r_light.svg", - include_bytes!("../../../assets/langs/r_light.svg"), - ), - ( - "react.svg", - include_bytes!("../../../assets/langs/react.svg"), - ), - ( - "react_light.svg", - include_bytes!("../../../assets/langs/react_light.svg"), - ), - ("ruby.svg", include_bytes!("../../../assets/langs/ruby.svg")), - ("rust.svg", include_bytes!("../../../assets/langs/rust.svg")), - ( - "rust_light.svg", - include_bytes!("../../../assets/langs/rust_light.svg"), - ), - ("sass.svg", include_bytes!("../../../assets/langs/sass.svg")), - ( - "scala.svg", - include_bytes!("../../../assets/langs/scala.svg"), - ), - ( - "solidity.svg", - include_bytes!("../../../assets/langs/solidity.svg"), - ), - ("sql.svg", include_bytes!("../../../assets/langs/sql.svg")), - ( - "sql_light.svg", - include_bytes!("../../../assets/langs/sql_light.svg"), - ), - ( - "svelte.svg", - include_bytes!("../../../assets/langs/svelte.svg"), - ), - ( - "swift.svg", - include_bytes!("../../../assets/langs/swift.svg"), - ), - ( - "terraform.svg", - include_bytes!("../../../assets/langs/terraform.svg"), - ), - ( - "typescript.svg", - include_bytes!("../../../assets/langs/typescript.svg"), - ), - ("vue.svg", include_bytes!("../../../assets/langs/vue.svg")), - ("zig.svg", include_bytes!("../../../assets/langs/zig.svg")), -]; - -pub struct AppAssets; - -impl AssetSource for AppAssets { - fn load(&self, path: &str) -> Result>> { - if let Some(bytes) = brand_asset(path) { - return Ok(Some(Cow::Borrowed(bytes))); - } - - if let Some(bytes) = lang_asset(path) { - return Ok(Some(Cow::Borrowed(bytes))); - } - - if let Some(svg) = component_compat_icon(path) { - return Ok(Some(Cow::Owned(svg.into_bytes()))); - } - - let Some(icon) = IconName::from_path(path) else { - return Ok(None); - }; - - let Some(svg) = icon.to_svg() else { - return Ok(None); - }; - - Ok(Some(Cow::Owned(svg.into_bytes()))) - } - - fn list(&self, path: &str) -> Result> { - let prefix = match path { - "icons" => "icons/", - "langs" => "langs/", - _ => return Ok(Vec::new()), - }; - Ok(IconName::ALL - .iter() - .filter_map(|icon| icon.path().strip_prefix(prefix)) - .map(SharedString::from) - .collect()) - } -} - -fn component_compat_icon(path: &str) -> Option { - let icon = match path { - "icons/circle-check.svg" => IconName::Pass, - "icons/circle-x.svg" => IconName::Error, - "icons/info.svg" => IconName::Info, - "icons/minus.svg" => IconName::Remove, - "icons/plus.svg" => IconName::Add, - "icons/triangle-alert.svg" => IconName::Warning, - _ => return None, - }; - icon.to_svg() -} - -fn brand_asset(path: &str) -> Option<&'static [u8]> { - match path { - "brand/kosmos-icon.svg" => Some(KOSMOS_ICON_SVG), - "brand/kosmos-text.svg" => Some(KOSMOS_TEXT_SVG), - "brand/kosmos-logo.svg" => Some(KOSMOS_LOGO_SVG), - _ => None, - } -} - -fn lang_asset(path: &str) -> Option<&'static [u8]> { - let name = path.strip_prefix("langs/")?; - LANG_ASSETS - .iter() - .find(|(n, _)| *n == name) - .map(|(_, bytes)| *bytes) -} - -/// True if a `_light.svg` variant is bundled — designed for use on light -/// backgrounds (the default lang SVG is the dark-bg, light-content version). -pub(crate) fn has_light_variant(path: &str) -> bool { - let Some(name) = path.strip_prefix("langs/") else { - return false; - }; - let Some(stem) = name.strip_suffix(".svg") else { - return false; - }; - let light = format!("{stem}_light.svg"); - LANG_ASSETS.iter().any(|(n, _)| *n == light) -} diff --git a/crates/icons/src/language.rs b/crates/icons/src/language.rs deleted file mode 100644 index 1e80b975..00000000 --- a/crates/icons/src/language.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::IconName; - -pub(crate) fn icon_for_file_name(file_name: &str) -> Option { - let lower = file_name.to_ascii_lowercase(); - let icon = match lower.as_str() { - "cargo.toml" | "cargo.lock" => IconName::LangRust, - "bun.lock" | "bun.lockb" | "bunfig.toml" => IconName::LangBun, - "package.json" | "package-lock.json" => IconName::LangJavascript, - "tsconfig.json" => IconName::LangTypescript, - "go.mod" | "go.sum" => IconName::LangGo, - "gemfile" | "gemfile.lock" => IconName::LangRuby, - "dockerfile" | "containerfile" | ".dockerignore" => IconName::LangDocker, - ".gitignore" | ".gitattributes" | ".gitmodules" => IconName::LangGit, - _ => return None, - }; - Some(icon) -} - -pub(crate) fn icon_for_language(id: &str) -> Option { - let icon = match id { - "astro" => IconName::LangAstro, - "c" => IconName::LangC, - "cpp" => IconName::LangCpp, - "csharp" => IconName::LangCsharp, - "css" => IconName::LangCss, - "dart" => IconName::LangDart, - "dockerfile" => IconName::LangDocker, - "dotenv" => IconName::LangDotenv, - "gitignore" => IconName::LangGit, - "go" => IconName::LangGo, - "graphql" => IconName::LangGraphql, - "haskell" => IconName::LangHaskell, - "hcl" | "terraform" => IconName::LangTerraform, - "html" => IconName::LangHtml, - "java" => IconName::LangJava, - "javascript" => IconName::LangJavascript, - "javascriptreact" | "typescriptreact" => IconName::LangReact, - "json" | "json5" | "jsonc" => IconName::LangJson, - "julia" => IconName::LangJulia, - "kotlin" => IconName::LangKotlin, - "lua" => IconName::LangLua, - "markdown" | "mdx" => IconName::LangMarkdown, - "php" => IconName::LangPhp, - "powershell" => IconName::LangPowershell, - "python" => IconName::LangPython, - "r" => IconName::LangR, - "ruby" => IconName::LangRuby, - "rust" => IconName::LangRust, - "sass" | "scss" => IconName::LangSass, - "scala" => IconName::LangScala, - "shellscript" => IconName::LangBash, - "solidity" => IconName::LangSolidity, - "sql" => IconName::LangSql, - "svelte" => IconName::LangSvelte, - "swift" => IconName::LangSwift, - "typescript" => IconName::LangTypescript, - "vue" => IconName::LangVue, - "zig" => IconName::LangZig, - _ => return None, - }; - Some(icon) -} diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs deleted file mode 100644 index b093d47f..00000000 --- a/crates/icons/src/lib.rs +++ /dev/null @@ -1,456 +0,0 @@ -mod assets; -mod language; -mod raster; - -pub use assets::*; - -use gpui::{ - App, IntoElement, RenderOnce, Rgba, SharedString, Window, canvas, prelude::*, rems, svg, -}; -use icondata_core::Icon as IconData; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum IconName { - Add, - Archive, - ArrowDown, - ArrowUp, - Blank, - ChevronDown, - ChevronRight, - ChromeClose, - ChromeMaximize, - ChromeMinimize, - ChromeRestore, - Clippy, - Close, - CollapseAll, - Copy, - Diff, - Edit, - Ellipsis, - EmptyWindow, - Error, - File, - FileAdd, - Folder, - FolderAdd, - FolderOpened, - Info, - ListTree, - Pass, - Refresh, - Remove, - Search, - Server, - SettingsGear, - SourceControl, - SplitHorizontal, - SplitVertical, - Terminal, - Tag, - Trash, - Warning, - LangAstro, - LangBash, - LangBun, - LangC, - LangCpp, - LangCsharp, - LangCss, - LangDart, - LangDocker, - LangDotenv, - LangGit, - LangGo, - LangGraphql, - LangHaskell, - LangHtml, - LangJava, - LangJavascript, - LangJson, - LangJulia, - LangKotlin, - LangLua, - LangMarkdown, - LangPhp, - LangPowershell, - LangPython, - LangR, - LangReact, - LangRuby, - LangRust, - LangSass, - LangScala, - LangSolidity, - LangSql, - LangSvelte, - LangSwift, - LangTerraform, - LangTypescript, - LangVue, - LangZig, -} - -impl IconName { - pub const ALL: &'static [Self] = &[ - Self::Add, - Self::Archive, - Self::ArrowDown, - Self::ArrowUp, - Self::Blank, - Self::ChevronDown, - Self::ChevronRight, - Self::ChromeClose, - Self::ChromeMaximize, - Self::ChromeMinimize, - Self::ChromeRestore, - Self::Clippy, - Self::Close, - Self::CollapseAll, - Self::Copy, - Self::Diff, - Self::Edit, - Self::Ellipsis, - Self::EmptyWindow, - Self::Error, - Self::File, - Self::FileAdd, - Self::Folder, - Self::FolderAdd, - Self::FolderOpened, - Self::Info, - Self::ListTree, - Self::Pass, - Self::Refresh, - Self::Remove, - Self::Search, - Self::Server, - Self::SettingsGear, - Self::SourceControl, - Self::SplitHorizontal, - Self::SplitVertical, - Self::Terminal, - Self::Tag, - Self::Trash, - Self::Warning, - Self::LangAstro, - Self::LangBash, - Self::LangBun, - Self::LangC, - Self::LangCpp, - Self::LangCsharp, - Self::LangCss, - Self::LangDart, - Self::LangDocker, - Self::LangDotenv, - Self::LangGit, - Self::LangGo, - Self::LangGraphql, - Self::LangHaskell, - Self::LangHtml, - Self::LangJava, - Self::LangJavascript, - Self::LangJson, - Self::LangJulia, - Self::LangKotlin, - Self::LangLua, - Self::LangMarkdown, - Self::LangPhp, - Self::LangPowershell, - Self::LangPython, - Self::LangR, - Self::LangReact, - Self::LangRuby, - Self::LangRust, - Self::LangSass, - Self::LangScala, - Self::LangSolidity, - Self::LangSql, - Self::LangSvelte, - Self::LangSwift, - Self::LangTerraform, - Self::LangTypescript, - Self::LangVue, - Self::LangZig, - ]; - - pub fn path(self) -> &'static str { - match self { - Self::Add => "icons/add.svg", - Self::Archive => "icons/archive.svg", - Self::ArrowDown => "icons/arrow-down.svg", - Self::ArrowUp => "icons/arrow-up.svg", - Self::Blank => "icons/blank.svg", - Self::ChevronDown => "icons/chevron-down.svg", - Self::ChevronRight => "icons/chevron-right.svg", - Self::ChromeClose => "icons/chrome-close.svg", - Self::ChromeMaximize => "icons/chrome-maximize.svg", - Self::ChromeMinimize => "icons/chrome-minimize.svg", - Self::ChromeRestore => "icons/chrome-restore.svg", - Self::Clippy => "icons/clippy.svg", - Self::Close => "icons/close.svg", - Self::CollapseAll => "icons/collapse-all.svg", - Self::Copy => "icons/copy.svg", - Self::Diff => "icons/diff.svg", - Self::Edit => "icons/edit.svg", - Self::Ellipsis => "icons/ellipsis.svg", - Self::EmptyWindow => "icons/empty-window.svg", - Self::Error => "icons/error.svg", - Self::File => "icons/file.svg", - Self::FileAdd => "icons/file-add.svg", - Self::Folder => "icons/folder.svg", - Self::FolderAdd => "icons/folder-add.svg", - Self::FolderOpened => "icons/folder-opened.svg", - Self::Info => "icons/info.svg", - Self::ListTree => "icons/list-tree.svg", - Self::Pass => "icons/pass.svg", - Self::Refresh => "icons/refresh.svg", - Self::Remove => "icons/remove.svg", - Self::Search => "icons/search.svg", - Self::Server => "icons/server.svg", - Self::SettingsGear => "icons/settings-gear.svg", - Self::SourceControl => "icons/source-control.svg", - Self::SplitHorizontal => "icons/split-horizontal.svg", - Self::SplitVertical => "icons/split-vertical.svg", - Self::Terminal => "icons/terminal.svg", - Self::Tag => "icons/tag.svg", - Self::Trash => "icons/trash.svg", - Self::Warning => "icons/warning.svg", - Self::LangAstro => "langs/astro.svg", - Self::LangBash => "langs/bash.svg", - Self::LangBun => "langs/bun.svg", - Self::LangC => "langs/c.svg", - Self::LangCpp => "langs/cpp.svg", - Self::LangCsharp => "langs/csharp.svg", - Self::LangCss => "langs/css.svg", - Self::LangDart => "langs/dart.svg", - Self::LangDocker => "langs/docker.svg", - Self::LangDotenv => "langs/dotenv.svg", - Self::LangGit => "langs/git.svg", - Self::LangGo => "langs/go.svg", - Self::LangGraphql => "langs/graphql.svg", - Self::LangHaskell => "langs/haskell.svg", - Self::LangHtml => "langs/html.svg", - Self::LangJava => "langs/java.svg", - Self::LangJavascript => "langs/javascript.svg", - Self::LangJson => "langs/json.svg", - Self::LangJulia => "langs/julia.svg", - Self::LangKotlin => "langs/kotlin.svg", - Self::LangLua => "langs/lua.svg", - Self::LangMarkdown => "langs/markdown.svg", - Self::LangPhp => "langs/php.svg", - Self::LangPowershell => "langs/powershell.svg", - Self::LangPython => "langs/python.svg", - Self::LangR => "langs/r.svg", - Self::LangReact => "langs/react.svg", - Self::LangRuby => "langs/ruby.svg", - Self::LangRust => "langs/rust.svg", - Self::LangSass => "langs/sass.svg", - Self::LangScala => "langs/scala.svg", - Self::LangSolidity => "langs/solidity.svg", - Self::LangSql => "langs/sql.svg", - Self::LangSvelte => "langs/svelte.svg", - Self::LangSwift => "langs/swift.svg", - Self::LangTerraform => "langs/terraform.svg", - Self::LangTypescript => "langs/typescript.svg", - Self::LangVue => "langs/vue.svg", - Self::LangZig => "langs/zig.svg", - } - } - - pub fn from_path(path: &str) -> Option { - Self::ALL.iter().copied().find(|icon| icon.path() == path) - } - - pub fn for_language(language_id: &str) -> Option { - language::icon_for_language(language_id) - } - - /// Match well-known file names that should override extension-based icons - /// (e.g. `Cargo.toml` → Rust, `bun.lock` → Bun). - pub fn for_file_name(file_name: &str) -> Option { - language::icon_for_file_name(file_name) - } - - fn data(self) -> Option { - let data = match self { - Self::Add => icondata_vs::VsAdd, - Self::Archive => icondata_vs::VsArchive, - Self::ArrowDown => icondata_vs::VsArrowDown, - Self::ArrowUp => icondata_vs::VsArrowUp, - Self::Blank => icondata_vs::VsBlank, - Self::ChevronDown => icondata_vs::VsChevronDown, - Self::ChevronRight => icondata_vs::VsChevronRight, - Self::ChromeClose => icondata_vs::VsChromeClose, - Self::ChromeMaximize => icondata_vs::VsChromeMaximize, - Self::ChromeMinimize => icondata_vs::VsChromeMinimize, - Self::ChromeRestore => icondata_vs::VsChromeRestore, - Self::Clippy => icondata_vs::VsClippy, - Self::Close => icondata_vs::VsClose, - Self::CollapseAll => icondata_vs::VsCollapseAll, - Self::Copy => icondata_vs::VsCopy, - Self::Diff => icondata_vs::VsDiff, - Self::Edit => icondata_vs::VsEdit, - Self::Ellipsis => icondata_vs::VsEllipsis, - Self::EmptyWindow => icondata_vs::VsEmptyWindow, - Self::Error => icondata_vs::VsError, - Self::File => icondata_vs::VsFile, - Self::FileAdd => icondata_vs::VsNewFile, - Self::Folder => icondata_vs::VsFolder, - Self::FolderAdd => icondata_vs::VsNewFolder, - Self::FolderOpened => icondata_vs::VsFolderOpened, - Self::Info => icondata_vs::VsInfo, - Self::ListTree => icondata_vs::VsListTree, - Self::Pass => icondata_vs::VsPass, - Self::Refresh => icondata_vs::VsRefresh, - Self::Remove => icondata_vs::VsRemove, - Self::Search => icondata_vs::VsSearch, - Self::Server => icondata_vs::VsServer, - Self::SettingsGear => icondata_vs::VsSettingsGear, - Self::SourceControl => icondata_vs::VsSourceControl, - Self::SplitHorizontal => icondata_vs::VsSplitHorizontal, - Self::SplitVertical => icondata_vs::VsSplitVertical, - Self::Terminal => icondata_vs::VsTerminal, - Self::Tag => icondata_vs::VsTag, - Self::Trash => icondata_vs::VsTrash, - Self::Warning => icondata_vs::VsWarning, - Self::LangAstro - | Self::LangBash - | Self::LangBun - | Self::LangC - | Self::LangCpp - | Self::LangCsharp - | Self::LangCss - | Self::LangDart - | Self::LangDocker - | Self::LangDotenv - | Self::LangGit - | Self::LangGo - | Self::LangGraphql - | Self::LangHaskell - | Self::LangHtml - | Self::LangJava - | Self::LangJavascript - | Self::LangJson - | Self::LangJulia - | Self::LangKotlin - | Self::LangLua - | Self::LangMarkdown - | Self::LangPhp - | Self::LangPowershell - | Self::LangPython - | Self::LangR - | Self::LangReact - | Self::LangRuby - | Self::LangRust - | Self::LangSass - | Self::LangScala - | Self::LangSolidity - | Self::LangSql - | Self::LangSvelte - | Self::LangSwift - | Self::LangTerraform - | Self::LangTypescript - | Self::LangVue - | Self::LangZig => return None, - }; - Some(data) - } - - pub fn to_svg(self) -> Option { - let data = self.data()?; - let mut svg = String::from(r#"'); - svg.push_str(data.data); - svg.push_str(""); - Some(svg) - } -} - -fn push_attr(svg: &mut String, name: &str, value: Option<&str>) { - let Some(value) = value else { - return; - }; - - svg.push(' '); - svg.push_str(name); - svg.push_str(r#"=""#); - svg.push_str(value); - svg.push('"'); -} - -#[derive(IntoElement)] -pub struct Icon { - name: IconName, - size: f32, - color: Option, -} - -impl Icon { - pub fn new(name: IconName) -> Self { - Self { - name, - size: 16.0, - color: None, - } - } - - pub fn size(mut self, size: f32) -> Self { - self.size = size; - self - } - - pub fn size_rem(mut self, size_rem: f32) -> Self { - self.size = size_rem * 16.0; - self - } - - pub fn color(mut self, color: Rgba) -> Self { - self.color = Some(color); - self - } -} - -impl RenderOnce for Icon { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let path = self.name.path(); - let size = rems(self.size / 16.0); - // Multi-color SVGs (language icons) need per-paint rasterization at the - // element's device-pixel bounds — gpui's `img()` caches a single bitmap - // and stretches it, which produces visible aliasing/blur on zoom. - // `svg()` already does per-paint rasterization but forces a single - // tint color, so we can only use it for the monochrome icondata icons. - if path.starts_with("langs/") { - let asset_path = SharedString::from(path); - canvas( - |_bounds, _window, _cx| (), - move |bounds, _, window, cx| { - raster::paint(asset_path, bounds, window, cx); - }, - ) - .size(size) - .flex_none() - .into_any_element() - } else { - let mut element = svg().path(path).size(size).flex_none(); - if let Some(color) = self.color { - element = element.text_color(color); - } - element.into_any_element() - } - } -} diff --git a/crates/icons/src/raster.rs b/crates/icons/src/raster.rs deleted file mode 100644 index a0518be0..00000000 --- a/crates/icons/src/raster.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Per-paint rasterization for multi-color SVGs. -//! -//! gpui's `img()` rasterizes an SVG once and caches a single bitmap, which the -//! GPU then stretches at any display size. For small file-tree icons that means -//! either heavy downsampling artifacts or blurry upscaling on zoom. -//! -//! We rasterize each lang SVG at the element's actual device-pixel bounds during -//! paint (matching what a browser does), keyed by `(path, w, h)`. We use resvg -//! / usvg directly because gpui's own `SvgRenderer::render_pixmap` takes an -//! `SvgSize` enum that isn't publicly re-exported. - -use std::collections::HashMap; -use std::sync::{Arc, LazyLock, Mutex}; - -use gpui::{App, Bounds, Pixels, RenderImage, SharedString, Window}; -use image::{Frame, ImageBuffer}; -use resvg::{tiny_skia, usvg}; -use smallvec::SmallVec; -use theme::ActiveTheme; - -use crate::assets::has_light_variant; - -/// Soft cap. Each entry is roughly w*h*4 bytes plus overhead; with ~60 icons × -/// a handful of zoom levels we should stay well under this. -const CACHE_LIMIT: usize = 512; - -type CacheKey = (SharedString, u32, u32); - -static CACHE: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -pub(crate) fn paint(path: SharedString, bounds: Bounds, window: &mut Window, cx: &mut App) { - let scale = window.scale_factor(); - let device = bounds.size.scale(scale); - let w = u32::from(device.width).max(1); - let h = u32::from(device.height).max(1); - - let resolved = resolve_path_for_theme(path, cx); - let Some(image) = render_or_cache(&resolved, w, h, cx) else { - return; - }; - let _ = window.paint_image(bounds, Default::default(), image, 0, false); -} - -/// On light themes, swap to the `_light.svg` variant when one exists. The -/// default lang SVGs are designed for dark backgrounds (white-on-transparent), -/// which would be invisible on a light theme. -fn resolve_path_for_theme(path: SharedString, cx: &App) -> SharedString { - if cx.theme().is_dark { - return path; - } - if !has_light_variant(&path) { - return path; - } - let Some(stem) = path.strip_suffix(".svg") else { - return path; - }; - SharedString::from(format!("{stem}_light.svg")) -} - -fn render_or_cache(path: &SharedString, w: u32, h: u32, cx: &mut App) -> Option> { - let key = (path.clone(), w, h); - if let Some(image) = CACHE.lock().ok().and_then(|c| c.get(&key).cloned()) { - return Some(image); - } - - let bytes = cx.asset_source().load(path).ok().flatten()?; - let tree = usvg::Tree::from_data(&bytes, &usvg::Options::default()).ok()?; - let svg_size = tree.size(); - let scale_x = w as f32 / svg_size.width(); - let scale_y = h as f32 / svg_size.height(); - - let mut pixmap = tiny_skia::Pixmap::new(w, h)?; - let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); - resvg::render(&tree, transform, &mut pixmap.as_mut()); - - // resvg returns RGBA with premultiplied alpha; gpui's paint_image expects - // straight BGRA. Convert in place. - let mut data = pixmap.take(); - for px in data.chunks_exact_mut(4) { - px.swap(0, 2); - if px[3] > 0 { - let a = px[3] as f32 / 255.0; - px[0] = ((px[0] as f32) / a).min(255.0) as u8; - px[1] = ((px[1] as f32) / a).min(255.0) as u8; - px[2] = ((px[2] as f32) / a).min(255.0) as u8; - } - } - - let buffer = ImageBuffer::from_raw(w, h, data)?; - let image = Arc::new(RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1))); - - if let Ok(mut cache) = CACHE.lock() { - if cache.len() > CACHE_LIMIT { - cache.clear(); - } - cache.insert(key, image.clone()); - } - - Some(image) -} diff --git a/crates/installer/Cargo.toml b/crates/installer/Cargo.toml deleted file mode 100644 index 2f1bdca7..00000000 --- a/crates/installer/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "installer" -version = "0.4.9" -edition = "2024" - -[dependencies] -flate2 = "1" -registry = { path = "../registry" } -ureq = "2" -zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/crates/installer/README.md b/crates/installer/README.md deleted file mode 100644 index beaf51b3..00000000 --- a/crates/installer/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# installer - -Role: tool installation and discovery. - -Owns: -- Installing registry-defined tools into the Kosmos tool cache. -- Platform target detection. -- Package-manager and GitHub release installation flows. -- Installed binary path resolution. - -Does Not Own: -- Tool registry metadata. -- Settings UI for install buttons. -- LSP, formatter, or linter protocol behavior. diff --git a/crates/installer/src/lib.rs b/crates/installer/src/lib.rs deleted file mode 100644 index 16369db1..00000000 --- a/crates/installer/src/lib.rs +++ /dev/null @@ -1,354 +0,0 @@ -use std::fs; -use std::io::{self, Seek, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use registry::{GithubAsset, InstallSource, LaunchSpec, RegistryEntry, Target}; - -pub struct InstalledTool { - pub bin_path: PathBuf, - pub launch: &'static LaunchSpec, -} - -#[derive(Debug)] -pub enum InstallError { - Io(io::Error), - UnsupportedPlatform, - PackageManagerMissing(&'static str), - CommandFailed { tool: &'static str, message: String }, - BinaryMissing(PathBuf), - SourceUnsupported(&'static str), -} - -impl From for InstallError { - fn from(e: io::Error) -> Self { - InstallError::Io(e) - } -} - -impl std::fmt::Display for InstallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - InstallError::Io(e) => write!(f, "io error: {e}"), - InstallError::UnsupportedPlatform => write!(f, "unsupported platform"), - InstallError::PackageManagerMissing(name) => { - write!(f, "missing package manager: {name}") - } - InstallError::CommandFailed { tool, message } => { - write!(f, "{tool} failed: {message}") - } - InstallError::BinaryMissing(path) => { - write!(f, "expected binary at {} after install", path.display()) - } - InstallError::SourceUnsupported(name) => { - write!(f, "install source not yet supported: {name}") - } - } - } -} - -impl std::error::Error for InstallError {} - -pub fn cache_dir() -> PathBuf { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - home.join(".kosmos").join("tools") -} - -pub fn tool_dir(entry: &RegistryEntry) -> PathBuf { - cache_dir().join(entry.id) -} - -pub fn bin_path(entry: &RegistryEntry) -> Option { - let dir = tool_dir(entry); - match &entry.install { - InstallSource::Npm { bin, .. } => Some(dir.join("node_modules").join(".bin").join(bin)), - InstallSource::Pip { bin, .. } => Some(dir.join("venv").join("bin").join(bin)), - InstallSource::Cargo { bin, .. } => Some(dir.join("bin").join(bin)), - InstallSource::Go { bin, .. } => Some(dir.join("bin").join(bin)), - InstallSource::GithubRelease { assets, .. } => { - let target = current_target()?; - assets - .iter() - .find(|a| a.target == target) - .map(|a| dir.join(a.bin)) - } - } -} - -pub fn current_target() -> Option { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => Some(Target::LinuxX64Gnu), - ("linux", "aarch64") => Some(Target::LinuxArm64Gnu), - ("macos", "x86_64") => Some(Target::DarwinX64), - ("macos", "aarch64") => Some(Target::DarwinArm64), - ("windows", "x86_64") => Some(Target::WinX64), - ("windows", "aarch64") => Some(Target::WinArm64), - _ => None, - } -} - -pub fn is_installed(entry: &RegistryEntry) -> bool { - bin_path(entry).map(|p| p.exists()).unwrap_or(false) -} - -pub fn ensure(entry: &'static RegistryEntry) -> Result { - let dir = tool_dir(entry); - match &entry.install { - InstallSource::Npm { - package, - bin, - extra_packages, - } => ensure_npm(entry, &dir, package, bin, extra_packages), - InstallSource::Pip { - package, - bin, - extra_packages, - } => ensure_pip(entry, &dir, package, bin, extra_packages), - InstallSource::Cargo { crate_name, bin } => ensure_cargo(entry, &dir, crate_name, bin), - InstallSource::Go { module, bin } => ensure_go(entry, &dir, module, bin), - InstallSource::GithubRelease { repo, assets } => { - ensure_github_release(entry, &dir, repo, assets) - } - } -} - -fn run(cmd: &mut Command, tool: &'static str) -> Result<(), InstallError> { - let output = cmd.output().map_err(|e| { - if e.kind() == io::ErrorKind::NotFound { - InstallError::PackageManagerMissing(tool) - } else { - InstallError::Io(e) - } - })?; - if !output.status.success() { - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(InstallError::CommandFailed { - tool, - message: if message.is_empty() { - format!("exit status {}", output.status) - } else { - message - }, - }); - } - Ok(()) -} - -fn finish(bin_path: PathBuf, entry: &'static RegistryEntry) -> Result { - if !bin_path.exists() { - return Err(InstallError::BinaryMissing(bin_path)); - } - Ok(InstalledTool { - bin_path, - launch: &entry.launch, - }) -} - -fn ensure_npm( - entry: &'static RegistryEntry, - dir: &Path, - package: &str, - bin: &str, - extra_packages: &[&str], -) -> Result { - let bin_path = dir.join("node_modules").join(".bin").join(bin); - if bin_path.exists() { - return finish(bin_path, entry); - } - fs::create_dir_all(dir)?; - let mut cmd = Command::new("npm"); - cmd.arg("install") - .arg("--prefix") - .arg(dir) - .arg("--silent") - .arg("--no-progress") - .arg("--no-fund") - .arg("--no-audit") - .arg(package) - .args(extra_packages.iter().copied()); - run(&mut cmd, "npm")?; - finish(bin_path, entry) -} - -fn ensure_cargo( - entry: &'static RegistryEntry, - dir: &Path, - crate_name: &str, - bin: &str, -) -> Result { - let bin_path = dir.join("bin").join(bin); - if bin_path.exists() { - return finish(bin_path, entry); - } - fs::create_dir_all(dir)?; - let mut cmd = Command::new("cargo"); - cmd.arg("install") - .arg("--root") - .arg(dir) - .arg("--quiet") - .arg(crate_name); - run(&mut cmd, "cargo")?; - finish(bin_path, entry) -} - -fn ensure_go( - entry: &'static RegistryEntry, - dir: &Path, - module: &str, - bin: &str, -) -> Result { - let bin_dir = dir.join("bin"); - let bin_path = bin_dir.join(bin); - if bin_path.exists() { - return finish(bin_path, entry); - } - fs::create_dir_all(&bin_dir)?; - let mut cmd = Command::new("go"); - cmd.arg("install") - .arg(format!("{module}@latest")) - .env("GOBIN", &bin_dir); - run(&mut cmd, "go")?; - finish(bin_path, entry) -} - -fn ensure_github_release( - entry: &'static RegistryEntry, - dir: &Path, - repo: &str, - assets: &[GithubAsset], -) -> Result { - let target = current_target().ok_or(InstallError::UnsupportedPlatform)?; - let asset = assets - .iter() - .find(|a| a.target == target) - .ok_or(InstallError::UnsupportedPlatform)?; - - let bin_path = dir.join(asset.bin); - if bin_path.exists() { - return finish(bin_path, entry); - } - fs::create_dir_all(dir)?; - - let url = format!( - "https://github.com/{repo}/releases/latest/download/{file}", - file = asset.file - ); - let response = ureq::get(&url) - .call() - .map_err(|e| InstallError::CommandFailed { - tool: "github", - message: format!("download failed: {e}"), - })?; - let mut body = response.into_reader(); - - if asset.file.ends_with(".gz") { - let mut decoder = flate2::read::GzDecoder::new(&mut body); - let mut out = fs::File::create(&bin_path)?; - io::copy(&mut decoder, &mut out)?; - } else if asset.file.ends_with(".zip") { - let mut tmp = tempfile_in(dir, asset.file)?; - io::copy(&mut body, &mut tmp)?; - tmp.seek(io::SeekFrom::Start(0))?; - let mut archive = zip::ZipArchive::new(tmp).map_err(|e| InstallError::CommandFailed { - tool: "zip", - message: e.to_string(), - })?; - let mut entry_file = - archive - .by_name(asset.bin) - .map_err(|e| InstallError::CommandFailed { - tool: "zip", - message: format!("missing {} in archive: {e}", asset.bin), - })?; - let mut out = fs::File::create(&bin_path)?; - io::copy(&mut entry_file, &mut out)?; - } else if asset.file.ends_with(".tar.xz") { - let tmp_path = tempfile_path(dir, asset.file); - { - let mut tmp = tempfile_at(&tmp_path)?; - io::copy(&mut body, &mut tmp)?; - tmp.flush()?; - } - - let mut cmd = Command::new("tar"); - cmd.arg("-xJf") - .arg(&tmp_path) - .arg("-C") - .arg(dir) - .arg(asset.bin); - let result = run(&mut cmd, "tar"); - let _ = fs::remove_file(&tmp_path); - result?; - } else { - let mut out = fs::File::create(&bin_path)?; - io::copy(&mut body, &mut out)?; - } - - set_executable(&bin_path)?; - finish(bin_path, entry) -} - -#[cfg(unix)] -fn set_executable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(path)?.permissions(); - perms.set_mode(perms.mode() | 0o111); - fs::set_permissions(path, perms) -} - -#[cfg(not(unix))] -fn set_executable(_path: &Path) -> io::Result<()> { - Ok(()) -} - -fn tempfile_in(dir: &Path, file_name: &str) -> io::Result { - tempfile_at(&tempfile_path(dir, file_name)) -} - -fn tempfile_path(dir: &Path, file_name: &str) -> PathBuf { - dir.join(format!(".download-{file_name}")) -} - -fn tempfile_at(path: &Path) -> io::Result { - fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(true) - .open(path) -} - -fn ensure_pip( - entry: &'static RegistryEntry, - dir: &Path, - package: &str, - bin: &str, - extra_packages: &[&str], -) -> Result { - let venv_dir = dir.join("venv"); - let venv_bin = venv_dir.join("bin"); - let bin_path = venv_bin.join(bin); - if bin_path.exists() { - return finish(bin_path, entry); - } - fs::create_dir_all(dir)?; - - if !venv_dir.exists() { - let mut venv_cmd = Command::new("python3"); - venv_cmd.arg("-m").arg("venv").arg(&venv_dir); - run(&mut venv_cmd, "python3")?; - } - - let mut install_cmd = Command::new(venv_bin.join("pip")); - install_cmd - .arg("install") - .arg("--quiet") - .arg("--disable-pip-version-check") - .arg(package) - .args(extra_packages.iter().copied()); - run(&mut install_cmd, "pip")?; - - finish(bin_path, entry) -} diff --git a/crates/kosmos/Cargo.toml b/crates/kosmos/Cargo.toml deleted file mode 100644 index 9290bf4c..00000000 --- a/crates/kosmos/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "kosmos" -version = "0.4.9" -edition = "2024" - -[dependencies] -app_shell = { path = "../app_shell" } diff --git a/crates/kosmos/README.md b/crates/kosmos/README.md deleted file mode 100644 index 4e254600..00000000 --- a/crates/kosmos/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# kosmos - -Role: binary entry point. - -Owns: -- `main()`. -- Process-level bootstrap before handing control to `app_shell`. -- Future CLI argument parsing, logging setup, panic hooks, or alternate run modes. - -Does Not Own: -- Application state. -- Window setup. -- Feature orchestration. -- UI rendering or domain models. - -This crate should stay intentionally small. diff --git a/crates/kosmos/src/main.rs b/crates/kosmos/src/main.rs deleted file mode 100644 index ad2214ac..00000000 --- a/crates/kosmos/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - app_shell::run(); -} diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml deleted file mode 100644 index 9428b4d4..00000000 --- a/crates/language/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "language" -version = "0.4.9" -edition = "2024" - -[dependencies] diff --git a/crates/language/README.md b/crates/language/README.md deleted file mode 100644 index 26497b38..00000000 --- a/crates/language/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# language - -Role: language identity and file detection. - -Owns: -- `LanguageId` and language display metadata. -- File extension and special file-name detection. -- Canonical language ids shared by syntax, icons, registry, and UI. - -Does Not Own: -- Syntax grammars. -- Language server installation or transport. -- Icons or rendering. diff --git a/crates/language/src/lib.rs b/crates/language/src/lib.rs deleted file mode 100644 index 1eb3e693..00000000 --- a/crates/language/src/lib.rs +++ /dev/null @@ -1,465 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -#[derive(Clone, Eq, PartialEq, Hash, Debug)] -pub struct LanguageId(Arc); - -impl LanguageId { - pub fn new(s: impl Into>) -> Self { - Self(s.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl From<&str> for LanguageId { - fn from(s: &str) -> Self { - Self(Arc::from(s)) - } -} - -impl std::fmt::Display for LanguageId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -pub struct LanguageInfo { - pub id: &'static str, - pub name: &'static str, -} - -pub const ALL: &[LanguageInfo] = &[ - LanguageInfo { - id: "asciidoc", - name: "AsciiDoc", - }, - LanguageInfo { - id: "astro", - name: "Astro", - }, - LanguageInfo { - id: "batch", - name: "Batch", - }, - LanguageInfo { - id: "bazel", - name: "Bazel", - }, - LanguageInfo { id: "c", name: "C" }, - LanguageInfo { - id: "clojure", - name: "Clojure", - }, - LanguageInfo { - id: "cmake", - name: "CMake", - }, - LanguageInfo { - id: "cpp", - name: "C++", - }, - LanguageInfo { - id: "csharp", - name: "C#", - }, - LanguageInfo { - id: "css", - name: "CSS", - }, - LanguageInfo { id: "d", name: "D" }, - LanguageInfo { - id: "dart", - name: "Dart", - }, - LanguageInfo { - id: "dockerfile", - name: "Dockerfile", - }, - LanguageInfo { - id: "dotenv", - name: "dotenv", - }, - LanguageInfo { - id: "editorconfig", - name: "EditorConfig", - }, - LanguageInfo { - id: "elixir", - name: "Elixir", - }, - LanguageInfo { - id: "elm", - name: "Elm", - }, - LanguageInfo { - id: "erlang", - name: "Erlang", - }, - LanguageInfo { - id: "fish", - name: "Fish", - }, - LanguageInfo { - id: "fsharp", - name: "F#", - }, - LanguageInfo { - id: "gdscript", - name: "GDScript", - }, - LanguageInfo { - id: "gitignore", - name: "Git Ignore", - }, - LanguageInfo { - id: "go", - name: "Go", - }, - LanguageInfo { - id: "graphql", - name: "GraphQL", - }, - LanguageInfo { - id: "groovy", - name: "Groovy", - }, - LanguageInfo { - id: "haskell", - name: "Haskell", - }, - LanguageInfo { - id: "hcl", - name: "HCL", - }, - LanguageInfo { - id: "html", - name: "HTML", - }, - LanguageInfo { - id: "ini", - name: "INI", - }, - LanguageInfo { - id: "java", - name: "Java", - }, - LanguageInfo { - id: "javascript", - name: "JavaScript", - }, - LanguageInfo { - id: "javascriptreact", - name: "JavaScript JSX", - }, - LanguageInfo { - id: "json", - name: "JSON", - }, - LanguageInfo { - id: "json5", - name: "JSON5", - }, - LanguageInfo { - id: "jsonc", - name: "JSON with Comments", - }, - LanguageInfo { - id: "julia", - name: "Julia", - }, - LanguageInfo { - id: "kotlin", - name: "Kotlin", - }, - LanguageInfo { - id: "latex", - name: "LaTeX", - }, - LanguageInfo { - id: "less", - name: "Less", - }, - LanguageInfo { - id: "lua", - name: "Lua", - }, - LanguageInfo { - id: "makefile", - name: "Makefile", - }, - LanguageInfo { - id: "markdown", - name: "Markdown", - }, - LanguageInfo { - id: "mdx", - name: "MDX", - }, - LanguageInfo { - id: "nim", - name: "Nim", - }, - LanguageInfo { - id: "nix", - name: "Nix", - }, - LanguageInfo { - id: "ocaml", - name: "OCaml", - }, - LanguageInfo { - id: "org", - name: "Org Mode", - }, - LanguageInfo { - id: "perl", - name: "Perl", - }, - LanguageInfo { - id: "php", - name: "PHP", - }, - LanguageInfo { - id: "powershell", - name: "PowerShell", - }, - LanguageInfo { - id: "properties", - name: "Properties", - }, - LanguageInfo { - id: "proto", - name: "Protocol Buffers", - }, - LanguageInfo { - id: "python", - name: "Python", - }, - LanguageInfo { id: "r", name: "R" }, - LanguageInfo { - id: "rst", - name: "reStructuredText", - }, - LanguageInfo { - id: "ruby", - name: "Ruby", - }, - LanguageInfo { - id: "rust", - name: "Rust", - }, - LanguageInfo { - id: "sass", - name: "Sass", - }, - LanguageInfo { - id: "scala", - name: "Scala", - }, - LanguageInfo { - id: "scss", - name: "SCSS", - }, - LanguageInfo { - id: "shellscript", - name: "Shell", - }, - LanguageInfo { - id: "solidity", - name: "Solidity", - }, - LanguageInfo { - id: "sql", - name: "SQL", - }, - LanguageInfo { - id: "stylus", - name: "Stylus", - }, - LanguageInfo { - id: "svelte", - name: "Svelte", - }, - LanguageInfo { - id: "svg", - name: "SVG", - }, - LanguageInfo { - id: "swift", - name: "Swift", - }, - LanguageInfo { - id: "terraform", - name: "Terraform", - }, - LanguageInfo { - id: "toml", - name: "TOML", - }, - LanguageInfo { - id: "typescript", - name: "TypeScript", - }, - LanguageInfo { - id: "typescriptreact", - name: "TypeScript JSX", - }, - LanguageInfo { id: "v", name: "V" }, - LanguageInfo { - id: "vb", - name: "Visual Basic", - }, - LanguageInfo { - id: "vue", - name: "Vue", - }, - LanguageInfo { - id: "xml", - name: "XML", - }, - LanguageInfo { - id: "yaml", - name: "YAML", - }, - LanguageInfo { - id: "zig", - name: "Zig", - }, -]; - -pub fn info(id: &str) -> Option<&'static LanguageInfo> { - ALL.iter().find(|l| l.id == id) -} - -pub fn from_extension(ext: &str) -> Option { - let id = match ext.to_ascii_lowercase().as_str() { - // ─── Web / JS family ────────────────────────────────────────── - "ts" => "typescript", - "tsx" => "typescriptreact", - "js" | "mjs" | "cjs" => "javascript", - "jsx" => "javascriptreact", - "vue" => "vue", - "svelte" => "svelte", - "astro" => "astro", - - // ─── Systems ────────────────────────────────────────────────── - "rs" => "rust", - "go" => "go", - "c" | "h" => "c", - "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", - "zig" | "zon" => "zig", - "d" => "d", - "v" => "v", - "nim" => "nim", - - // ─── JVM / .NET ─────────────────────────────────────────────── - "java" => "java", - "kt" | "kts" => "kotlin", - "scala" | "sc" => "scala", - "groovy" => "groovy", - "cs" => "csharp", - "fs" | "fsx" => "fsharp", - "vb" => "vb", - - // ─── Functional ─────────────────────────────────────────────── - "hs" | "lhs" => "haskell", - "ml" | "mli" => "ocaml", - "elm" => "elm", - "ex" | "exs" => "elixir", - "erl" | "hrl" => "erlang", - "clj" | "cljs" | "cljc" => "clojure", - - // ─── Scripting ──────────────────────────────────────────────── - "py" | "pyi" | "pyw" => "python", - "rb" => "ruby", - "php" => "php", - "pl" | "pm" => "perl", - "lua" => "lua", - "r" => "r", - "jl" => "julia", - "dart" => "dart", - "swift" => "swift", - - // ─── Shell ──────────────────────────────────────────────────── - "sh" | "bash" | "zsh" => "shellscript", - "fish" => "fish", - "ps1" => "powershell", - "bat" | "cmd" => "batch", - - // ─── Markup / styles ────────────────────────────────────────── - "html" | "htm" => "html", - "css" => "css", - "scss" => "scss", - "sass" => "sass", - "less" => "less", - "styl" => "stylus", - - // ─── Data / config ──────────────────────────────────────────── - "json" => "json", - "jsonc" => "jsonc", - "json5" => "json5", - "yaml" | "yml" => "yaml", - "toml" => "toml", - // SVG keeps its own id so the UI labels it correctly; other XML - // dialects collapse onto `xml` since they share the grammar and - // there's no useful distinction at render time. - "svg" => "svg", - "xml" | "xsd" | "xsl" | "xslt" | "rss" | "atom" | "plist" => "xml", - "ini" | "cfg" | "conf" => "ini", - "env" => "dotenv", - "properties" => "properties", - - // ─── Docs ───────────────────────────────────────────────────── - "md" | "markdown" => "markdown", - "mdx" => "mdx", - "rst" => "rst", - "tex" | "latex" => "latex", - "adoc" | "asciidoc" => "asciidoc", - "org" => "org", - - // ─── IaC / build ────────────────────────────────────────────── - "tf" | "tfvars" => "terraform", - "hcl" => "hcl", - "nix" => "nix", - "dockerfile" => "dockerfile", - - // ─── Other ──────────────────────────────────────────────────── - "sql" => "sql", - "proto" => "proto", - "graphql" | "gql" => "graphql", - "sol" => "solidity", - "gd" => "gdscript", - - _ => return None, - }; - Some(LanguageId::from(id)) -} - -pub fn from_path(path: &Path) -> Option { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - let by_name = match name.to_ascii_lowercase().as_str() { - "dockerfile" | "containerfile" => Some("dockerfile"), - "makefile" | "gnumakefile" => Some("makefile"), - "cmakelists.txt" => Some("cmake"), - "build" | "build.bazel" | "workspace" | "workspace.bazel" => Some("bazel"), - ".gitignore" | ".gitattributes" | ".gitmodules" => Some("gitignore"), - ".env" => Some("dotenv"), - ".editorconfig" => Some("editorconfig"), - // Bun's lockfile is JSON-with-comments + trailing commas. JSONC - // routes to the JSON grammar in our registry; tree-sitter-json's - // error recovery handles the trailing commas well enough that the - // file colorizes cleanly. - "bun.lock" => Some("jsonc"), - _ => None, - }; - if let Some(id) = by_name { - return Some(LanguageId::from(id)); - } - } - path.extension() - .and_then(|e| e.to_str()) - .and_then(from_extension) -} diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml deleted file mode 100644 index f15152a7..00000000 --- a/crates/lsp/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "lsp" -version = "0.4.9" -edition = "2024" - -[dependencies] -installer = { path = "../installer" } -lsp-types = "0.97" -registry = { path = "../registry" } -serde_json = "1" -url = "2" diff --git a/crates/lsp/README.md b/crates/lsp/README.md deleted file mode 100644 index baa8fc31..00000000 --- a/crates/lsp/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# lsp - -Role: Language Server Protocol client runtime. - -Owns: -- LSP process startup for installed registry entries. -- Per-server, per-root client sessions. -- JSON-RPC request/response plumbing. -- Text document synchronization needed for hover and completion requests. -- Hover and completion request/response models. - -Does Not Own: -- Installing language servers. -- Registry definitions for language servers. -- Editor UI for hovers. -- Syntax highlighting. diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs deleted file mode 100644 index eb416eec..00000000 --- a/crates/lsp/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -include!("parts/types_and_sessions.rs"); -include!("parts/client.rs"); -include!("parts/protocol_helpers.rs"); -include!("parts/tests.rs"); diff --git a/crates/lsp/src/parts/client.rs b/crates/lsp/src/parts/client.rs deleted file mode 100644 index e3764480..00000000 --- a/crates/lsp/src/parts/client.rs +++ /dev/null @@ -1,346 +0,0 @@ -impl Client { - fn start(entry: &'static RegistryEntry, root: PathBuf) -> Result { - let bin_path = installer::bin_path(entry).ok_or_else(|| Error::Start { - server: entry.id, - message: "no binary for the current platform".to_string(), - })?; - - let mut command = Command::new(&bin_path); - command - .args(entry.launch.args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .current_dir(&root); - for &(key, value) in entry.launch.env { - command.env(key, value); - } - - let mut child = command.spawn().map_err(|err| Error::Start { - server: entry.id, - message: format!("{}: {err}", bin_path.display()), - })?; - let stdin = child.stdin.take().ok_or_else(|| Error::Start { - server: entry.id, - message: "missing stdin pipe".to_string(), - })?; - let stdout = child.stdout.take().ok_or_else(|| Error::Start { - server: entry.id, - message: "missing stdout pipe".to_string(), - })?; - - let inner = Arc::new(ClientInner { - server_id: entry.id, - child: Mutex::new(child), - stdin: Mutex::new(stdin), - pending: Mutex::new(HashMap::new()), - documents: Mutex::new(HashMap::new()), - sync_kind: Mutex::new(lsp_types::TextDocumentSyncKind::FULL), - diagnostics: Mutex::new(HashMap::new()), - diagnostics_changed: Condvar::new(), - next_id: AtomicU64::new(1), - }); - - spawn_reader(stdout, Arc::downgrade(&inner)); - - let client = Self { inner }; - client.initialize(entry, &root)?; - Ok(client) - } - - fn initialize(&self, entry: &'static RegistryEntry, root: &Path) -> Result<(), Error> { - let root_uri = file_uri(root)?; - let root_path = root.to_string_lossy().to_string(); - let result = self.request( - "initialize", - json!({ - "processId": std::process::id(), - "clientInfo": { - "name": "Kosmos", - "version": env!("CARGO_PKG_VERSION"), - }, - "rootUri": root_uri.as_str(), - "rootPath": root_path.as_str(), - "workspaceFolders": [{ - "uri": root_uri.as_str(), - "name": root.file_name().and_then(|name| name.to_str()).unwrap_or("workspace"), - }], - "capabilities": { - "textDocument": { - "hover": { - "dynamicRegistration": false, - "contentFormat": ["markdown", "plaintext"], - }, - "completion": { - "dynamicRegistration": false, - "contextSupport": true, - "completionItem": { - "documentationFormat": ["markdown", "plaintext"], - "deprecatedSupport": true, - "insertReplaceSupport": true, - "labelDetailsSupport": true, - "preselectSupport": true, - "snippetSupport": false, - }, - }, - "synchronization": { - "dynamicRegistration": false, - "willSave": false, - "willSaveWaitUntil": false, - "didSave": true, - }, - "publishDiagnostics": { - "dynamicRegistration": false, - "relatedInformation": true, - "versionSupport": true, - "tagSupport": { "valueSet": [1, 2] }, - "codeDescriptionSupport": true, - "dataSupport": true, - }, - }, - "workspace": { - "configuration": false, - "workspaceFolders": true, - }, - }, - }), - INITIALIZE_TIMEOUT, - )?; - self.inner - .set_sync_kind(text_document_sync_kind(&result)); - self.notify("initialized", json!({}))?; - - eprintln!("kosmos: started LSP {} for {}", entry.id, root.display()); - Ok(()) - } - - fn hover( - &self, - entry: &'static RegistryEntry, - request: &HoverRequest, - ) -> Result, Error> { - let uri = file_uri(&request.path)?; - self.ensure_document(entry, &request.language_id, &request.content, &uri)?; - - let result = self.request( - "textDocument/hover", - json!({ - "textDocument": { "uri": uri }, - "position": { - "line": request.position.line, - "character": request.position.character, - }, - }), - HOVER_TIMEOUT, - )?; - - Ok(hover_text(&result)) - } - - fn completion( - &self, - entry: &'static RegistryEntry, - request: &CompletionRequest, - ) -> Result, Error> { - let uri = file_uri(&request.path)?; - self.ensure_document(entry, &request.language_id, &request.content, &uri)?; - - let result = self.request( - "textDocument/completion", - json!({ - "textDocument": { "uri": uri }, - "position": { - "line": request.position.line, - "character": request.position.character, - }, - }), - COMPLETION_TIMEOUT, - )?; - - completion_response(result) - } - - fn diagnostics( - &self, - entry: &'static RegistryEntry, - request: &DiagnosticsRequest, - ) -> Result { - let uri = file_uri(&request.path)?; - let previous_epoch = self.inner.diagnostics_epoch(&uri); - let sync = self.ensure_document(entry, &request.language_id, &request.content, &uri)?; - let minimum_epoch = if sync.changed || previous_epoch == 0 { - Some(previous_epoch) - } else { - request.previous_epoch - }; - - if let Some(minimum_epoch) = minimum_epoch { - self.inner - .wait_for_diagnostics(&uri, sync.version, minimum_epoch, DIAGNOSTIC_TIMEOUT); - } - - Ok(self.inner.diagnostics_for(&uri, sync.version, minimum_epoch)) - } - - fn did_save( - &self, - entry: &'static RegistryEntry, - request: &SaveRequest, - ) -> Result<(), Error> { - let uri = file_uri(&request.path)?; - self.ensure_document(entry, &request.language_id, &request.content, &uri)?; - self.notify( - "textDocument/didSave", - json!({ - "textDocument": { "uri": uri }, - "text": request.content, - }), - ) - .map_err(|err| Error::Response { - server: entry.id, - message: err.to_string(), - }) - } - - fn ensure_document( - &self, - entry: &'static RegistryEntry, - language_id: &str, - content: &str, - uri: &str, - ) -> Result { - enum SyncAction { - Open { version: i32 }, - Change { - version: i32, - change: lsp_types::TextDocumentContentChangeEvent, - }, - None { version: i32 }, - } - - let sync_kind = self.inner.sync_kind(); - let action = { - let mut documents = self.inner.documents.lock().unwrap(); - match documents.get_mut(uri) { - Some(state) if state.content == content => SyncAction::None { - version: state.version, - }, - Some(state) => { - state.version += 1; - let change = text_document_content_change(sync_kind, &state.content, content); - state.content = content.to_string(); - SyncAction::Change { - version: state.version, - change, - } - } - None => { - documents.insert( - uri.to_string(), - DocumentState { - version: 1, - content: content.to_string(), - }, - ); - SyncAction::Open { version: 1 } - } - } - }; - - let result = match action { - SyncAction::Open { version } => self.notify( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": uri, - "languageId": language_id, - "version": version, - "text": content, - }, - }), - ), - SyncAction::Change { version, ref change } => self.notify( - "textDocument/didChange", - json!({ - "textDocument": { - "uri": uri, - "version": version, - }, - "contentChanges": [change], - }), - ), - SyncAction::None { .. } => Ok(()), - } - .map_err(|err| Error::Response { - server: entry.id, - message: err.to_string(), - }); - - result?; - - Ok(match action { - SyncAction::Open { version } | SyncAction::Change { version, .. } => DocumentSync { - version, - changed: true, - }, - SyncAction::None { version } => DocumentSync { - version, - changed: false, - }, - }) - } - - fn request( - &self, - method: &'static str, - params: Value, - timeout: Duration, - ) -> Result { - let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); - let (tx, rx) = mpsc::sync_channel(1); - self.inner.pending.lock().unwrap().insert(id, tx); - - let send_result = self.send(json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params, - })); - if let Err(err) = send_result { - self.inner.pending.lock().unwrap().remove(&id); - return Err(err); - } - - let response = match rx.recv_timeout(timeout) { - Ok(response) => response, - Err(_) => { - self.inner.pending.lock().unwrap().remove(&id); - return Err(Error::Timeout { - server: self.inner.server_id, - method, - }); - } - }; - - if let Some(error) = response.get("error") { - return Err(Error::Response { - server: self.inner.server_id, - message: response_error_message(error), - }); - } - - Ok(response.get("result").cloned().unwrap_or(Value::Null)) - } - - fn notify(&self, method: &'static str, params: Value) -> Result<(), Error> { - self.send(json!({ - "jsonrpc": "2.0", - "method": method, - "params": params, - })) - } - - fn send(&self, value: Value) -> Result<(), Error> { - self.inner.send(value) - } -} diff --git a/crates/lsp/src/parts/protocol_helpers.rs b/crates/lsp/src/parts/protocol_helpers.rs deleted file mode 100644 index 8aeca309..00000000 --- a/crates/lsp/src/parts/protocol_helpers.rs +++ /dev/null @@ -1,386 +0,0 @@ -impl ClientInner { - fn send(&self, value: Value) -> Result<(), Error> { - let body = serde_json::to_vec(&value)?; - let mut stdin = self.stdin.lock().unwrap(); - write!(stdin, "Content-Length: {}\r\n\r\n", body.len())?; - stdin.write_all(&body)?; - stdin.flush()?; - Ok(()) - } - - fn respond(&self, id: Value, result: Value) { - let _ = self.send(json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - })); - } - - fn set_sync_kind(&self, sync_kind: lsp_types::TextDocumentSyncKind) { - *self.sync_kind.lock().unwrap() = sync_kind; - } - - fn sync_kind(&self) -> lsp_types::TextDocumentSyncKind { - *self.sync_kind.lock().unwrap() - } - - fn publish_diagnostics(&self, params: lsp_types::PublishDiagnosticsParams) { - let uri = params.uri.to_string(); - let mut diagnostics = self.diagnostics.lock().unwrap(); - let epoch = diagnostics.get(&uri).map_or(1, |state| state.epoch + 1); - diagnostics.insert( - uri, - DiagnosticState { - version: params.version, - diagnostics: params.diagnostics, - epoch, - }, - ); - drop(diagnostics); - self.diagnostics_changed.notify_all(); - } - - fn diagnostics_epoch(&self, uri: &str) -> u64 { - self.diagnostics - .lock() - .unwrap() - .get(uri) - .map_or(0, |state| state.epoch) - } - - fn wait_for_diagnostics( - &self, - uri: &str, - version: i32, - previous_epoch: u64, - timeout: Duration, - ) { - let deadline = Instant::now() + timeout; - let mut diagnostics = self.diagnostics.lock().unwrap(); - while !diagnostics_are_fresh(diagnostics.get(uri), version, previous_epoch) { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break; - } - - let (next_diagnostics, result) = self - .diagnostics_changed - .wait_timeout(diagnostics, remaining) - .unwrap(); - diagnostics = next_diagnostics; - if result.timed_out() { - break; - } - } - } - - fn diagnostics_for( - &self, - uri: &str, - version: i32, - minimum_epoch: Option, - ) -> DiagnosticsResponse { - let diagnostics = self.diagnostics.lock().unwrap(); - let Some(state) = diagnostics.get(uri) else { - return DiagnosticsResponse { - diagnostics: Vec::new(), - epoch: 0, - fresh: false, - }; - }; - - let fresh = diagnostics_are_fresh(Some(state), version, minimum_epoch.unwrap_or(0)); - DiagnosticsResponse { - diagnostics: if fresh { - state.diagnostics.clone() - } else { - Vec::new() - }, - epoch: state.epoch, - fresh, - } - } -} - -fn spawn_reader(stdout: ChildStdout, client: Weak) { - thread::spawn(move || { - let mut reader = BufReader::new(stdout); - loop { - let message = match read_message(&mut reader) { - Ok(Some(message)) => message, - Ok(None) => break, - Err(err) => { - eprintln!("kosmos: LSP read error: {err}"); - break; - } - }; - - let value: Value = match serde_json::from_str(&message) { - Ok(value) => value, - Err(err) => { - eprintln!("kosmos: LSP JSON parse error: {err}"); - continue; - } - }; - - let Some(client) = client.upgrade() else { - break; - }; - - if let Some(method) = value.get("method").and_then(Value::as_str) { - if let Some(id) = value.get("id").cloned() { - let result = match method { - "workspace/configuration" => { - workspace_configuration_result(value.get("params").unwrap_or(&Value::Null)) - } - _ => Value::Null, - }; - client.respond(id, result); - continue; - } - - if method == "textDocument/publishDiagnostics" { - if let Some(params) = value.get("params").cloned() { - match serde_json::from_value::(params) - { - Ok(params) => client.publish_diagnostics(params), - Err(err) => eprintln!("kosmos: LSP diagnostics parse error: {err}"), - } - } else { - eprintln!("kosmos: LSP diagnostics notification missing params"); - } - continue; - } - } - - let Some(id) = value.get("id").and_then(Value::as_u64) else { - continue; - }; - if let Some(tx) = client.pending.lock().unwrap().remove(&id) { - let _ = tx.send(value); - } - } - }); -} - -fn workspace_configuration_result(params: &Value) -> Value { - let Some(items) = params.get("items").and_then(Value::as_array) else { - return json!([]); - }; - - Value::Array(items.iter().map(|_| Value::Null).collect()) -} - -fn text_document_sync_kind(result: &Value) -> lsp_types::TextDocumentSyncKind { - let Some(sync) = result.get("capabilities").and_then(|capabilities| { - capabilities.get("textDocumentSync") - }) else { - return lsp_types::TextDocumentSyncKind::FULL; - }; - - if let Some(kind) = sync.as_i64() { - return match kind { - 2 => lsp_types::TextDocumentSyncKind::INCREMENTAL, - 1 => lsp_types::TextDocumentSyncKind::FULL, - _ => lsp_types::TextDocumentSyncKind::NONE, - }; - } - - sync.get("change") - .and_then(Value::as_i64) - .map(|kind| match kind { - 2 => lsp_types::TextDocumentSyncKind::INCREMENTAL, - 1 => lsp_types::TextDocumentSyncKind::FULL, - _ => lsp_types::TextDocumentSyncKind::NONE, - }) - .unwrap_or(lsp_types::TextDocumentSyncKind::FULL) -} - -fn text_document_content_change( - sync_kind: lsp_types::TextDocumentSyncKind, - old: &str, - new: &str, -) -> lsp_types::TextDocumentContentChangeEvent { - if sync_kind != lsp_types::TextDocumentSyncKind::INCREMENTAL { - return lsp_types::TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: new.to_string(), - }; - } - - let prefix = common_prefix_len(old, new); - let (old_end, new_end) = common_suffix_offsets(old, new, prefix); - let range = lsp_types::Range::new( - lsp_position_for_offset(old, prefix), - lsp_position_for_offset(old, old_end), - ); - let range_length = old[prefix..old_end].encode_utf16().count() as u32; - - lsp_types::TextDocumentContentChangeEvent { - range: Some(range), - range_length: Some(range_length), - text: new[prefix..new_end].to_string(), - } -} - -fn common_prefix_len(a: &str, b: &str) -> usize { - let mut len = 0; - let mut a_chars = a.char_indices(); - let mut b_chars = b.char_indices(); - loop { - match (a_chars.next(), b_chars.next()) { - (Some((a_index, a_ch)), Some((_, b_ch))) if a_ch == b_ch => { - len = a_index + a_ch.len_utf8(); - } - _ => return len, - } - } -} - -fn common_suffix_offsets(a: &str, b: &str, prefix: usize) -> (usize, usize) { - let mut a_end = a.len(); - let mut b_end = b.len(); - while a_end > prefix && b_end > prefix { - let Some(a_ch) = a[..a_end].chars().next_back() else { - break; - }; - let Some(b_ch) = b[..b_end].chars().next_back() else { - break; - }; - if a_ch != b_ch { - break; - } - - a_end -= a_ch.len_utf8(); - b_end -= b_ch.len_utf8(); - } - (a_end, b_end) -} - -fn lsp_position_for_offset(text: &str, offset: usize) -> lsp_types::Position { - let mut line = 0; - let mut character = 0; - for ch in text[..offset].chars() { - if ch == '\n' { - line += 1; - character = 0; - } else { - character += ch.len_utf16() as u32; - } - } - lsp_types::Position::new(line, character) -} - -fn diagnostics_are_fresh( - state: Option<&DiagnosticState>, - version: i32, - minimum_epoch: u64, -) -> bool { - let Some(state) = state else { - return false; - }; - - if state.epoch <= minimum_epoch { - return false; - } - - match state.version { - Some(diagnostic_version) => diagnostic_version >= version, - None => true, - } -} - -fn read_message(reader: &mut BufReader) -> io::Result> { - let mut content_length = None; - loop { - let mut line = String::new(); - if reader.read_line(&mut line)? == 0 { - return Ok(None); - } - - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() { - break; - } - - if let Some(value) = line.strip_prefix("Content-Length:") { - content_length = value.trim().parse::().ok(); - } - } - - let Some(content_length) = content_length else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "missing Content-Length header", - )); - }; - - let mut body = vec![0; content_length]; - reader.read_exact(&mut body)?; - Ok(Some(String::from_utf8_lossy(&body).into_owned())) -} - -fn file_uri(path: &Path) -> Result { - Url::from_file_path(path) - .map(|url| url.to_string()) - .map_err(|_| Error::Url(path.to_path_buf())) -} - -fn response_error_message(error: &Value) -> String { - error - .get("message") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| error.to_string()) -} - -fn hover_text(result: &Value) -> Option { - if result.is_null() { - return None; - } - - let contents = result.get("contents")?; - let mut parts = Vec::new(); - collect_hover_parts(contents, &mut parts); - let text = normalize_hover_text(parts.join("\n\n")); - (!text.is_empty()).then_some(text) -} - -fn completion_response(result: Value) -> Result, Error> { - if result.is_null() { - return Ok(None); - } - - serde_json::from_value(result).map(Some).map_err(Error::from) -} - -fn collect_hover_parts(value: &Value, parts: &mut Vec) { - match value { - Value::String(text) => parts.push(text.clone()), - Value::Array(items) => { - for item in items { - collect_hover_parts(item, parts); - } - } - Value::Object(map) => { - if let (Some(language), Some(value)) = ( - map.get("language").and_then(Value::as_str), - map.get("value").and_then(Value::as_str), - ) { - parts.push(format!("```{language}\n{value}\n```")); - } else if let Some(value) = map.get("value").and_then(Value::as_str) { - parts.push(value.to_string()); - } - } - _ => {} - } -} - -fn normalize_hover_text(text: String) -> String { - let mut text = text.replace("\r\n", "\n").replace('\r', "\n"); - while text.contains("\n\n\n") { - text = text.replace("\n\n\n", "\n\n"); - } - text.trim().to_string() -} diff --git a/crates/lsp/src/parts/tests.rs b/crates/lsp/src/parts/tests.rs deleted file mode 100644 index 77b9f6ab..00000000 --- a/crates/lsp/src/parts/tests.rs +++ /dev/null @@ -1,134 +0,0 @@ -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_markup_content_hover() { - let result = json!({ - "contents": { - "kind": "markdown", - "value": "```rust\nfn main()\n```", - }, - }); - - assert_eq!(hover_text(&result).unwrap(), "```rust\nfn main()\n```"); - } - - #[test] - fn extracts_marked_string_arrays() { - let result = json!({ - "contents": [ - { "language": "rust", "value": "struct Foo" }, - "docs", - ], - }); - - assert_eq!( - hover_text(&result).unwrap(), - "```rust\nstruct Foo\n```\n\ndocs" - ); - } - - #[test] - fn ignores_empty_hover() { - let result = json!({ "contents": [] }); - - assert!(hover_text(&result).is_none()); - } - - #[test] - fn parses_completion_arrays() { - let result = json!([ - { "label": "println!", "kind": 3 }, - { "label": "print!", "detail": "macro" }, - ]); - - let response = completion_response(result).unwrap().unwrap(); - match response { - lsp_types::CompletionResponse::Array(items) => { - assert_eq!(items.len(), 2); - assert_eq!(items[0].label, "println!"); - assert_eq!(items[1].detail.as_deref(), Some("macro")); - } - lsp_types::CompletionResponse::List(_) => panic!("expected completion array"), - } - } - - #[test] - fn ignores_null_completion() { - assert!(completion_response(Value::Null).unwrap().is_none()); - } - - #[test] - fn builds_incremental_content_change() { - let old = "fn main() {\n let value = Ve::new();\n}\n"; - let new = "fn main() {\n let value = Vec::new();\n}\n"; - - let change = text_document_content_change( - lsp_types::TextDocumentSyncKind::INCREMENTAL, - old, - new, - ); - - assert_eq!(change.text, "c"); - assert_eq!(change.range_length, Some(0)); - assert_eq!( - change.range, - Some(lsp_types::Range::new( - lsp_types::Position::new(1, 18), - lsp_types::Position::new(1, 18), - )) - ); - } - - #[test] - fn incremental_content_change_uses_utf16_positions() { - let old = "fn main() {\n let value = \"🦀\";\n}\n"; - let new = "fn main() {\n let value = \"🦀x\";\n}\n"; - - let change = text_document_content_change( - lsp_types::TextDocumentSyncKind::INCREMENTAL, - old, - new, - ); - - assert_eq!(change.text, "x"); - assert_eq!( - change.range, - Some(lsp_types::Range::new( - lsp_types::Position::new(1, 19), - lsp_types::Position::new(1, 19), - )) - ); - } - - #[test] - fn builds_full_content_change_for_full_sync_servers() { - let change = text_document_content_change( - lsp_types::TextDocumentSyncKind::FULL, - "old", - "new", - ); - - assert_eq!(change.text, "new"); - assert_eq!(change.range, None); - assert_eq!(change.range_length, None); - } - - #[test] - fn parses_text_document_sync_kind_from_capabilities() { - let result = json!({ - "capabilities": { - "textDocumentSync": { - "openClose": true, - "change": 2, - }, - }, - }); - - assert_eq!( - text_document_sync_kind(&result), - lsp_types::TextDocumentSyncKind::INCREMENTAL - ); - } -} diff --git a/crates/lsp/src/parts/types_and_sessions.rs b/crates/lsp/src/parts/types_and_sessions.rs deleted file mode 100644 index fc88068f..00000000 --- a/crates/lsp/src/parts/types_and_sessions.rs +++ /dev/null @@ -1,313 +0,0 @@ -use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{self, SyncSender}; -use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak}; -use std::thread; -use std::time::{Duration, Instant}; - -use registry::{RegistryEntry, ToolKind}; -use serde_json::{Value, json}; -use url::Url; - -const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); -const HOVER_TIMEOUT: Duration = Duration::from_secs(2); -const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2); -const DIAGNOSTIC_TIMEOUT: Duration = Duration::from_secs(2); - -#[derive(Clone, Debug)] -pub struct HoverRequest { - pub root: PathBuf, - pub path: PathBuf, - pub language_id: String, - pub content: String, - pub position: Position, -} - -#[derive(Clone, Debug)] -pub struct CompletionRequest { - pub root: PathBuf, - pub path: PathBuf, - pub language_id: String, - pub content: String, - pub position: Position, -} - -#[derive(Clone, Debug)] -pub struct DiagnosticsRequest { - pub root: PathBuf, - pub path: PathBuf, - pub language_id: String, - pub content: String, - pub previous_epoch: Option, -} - -#[derive(Clone, Debug)] -pub struct SaveRequest { - pub root: PathBuf, - pub path: PathBuf, - pub language_id: String, - pub content: String, -} - -#[derive(Clone, Debug)] -pub struct DiagnosticsResponse { - pub diagnostics: Vec, - pub epoch: u64, - pub fresh: bool, -} - -#[derive(Clone, Copy, Debug)] -pub struct Position { - pub line: u32, - pub character: u32, -} - -#[derive(Clone, Debug)] -pub struct Hover { - pub contents: String, -} - -#[derive(Debug)] -pub enum Error { - Io(io::Error), - Json(serde_json::Error), - Url(PathBuf), - Start { - server: &'static str, - message: String, - }, - Timeout { - server: &'static str, - method: &'static str, - }, - Response { - server: &'static str, - message: String, - }, - AllFailed(String), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(err) => write!(f, "io error: {err}"), - Self::Json(err) => write!(f, "json error: {err}"), - Self::Url(path) => write!(f, "could not convert path to file URI: {}", path.display()), - Self::Start { server, message } => write!(f, "{server} failed to start: {message}"), - Self::Timeout { server, method } => write!(f, "{server} timed out during {method}"), - Self::Response { server, message } => { - write!(f, "{server} returned an error: {message}") - } - Self::AllFailed(message) => f.write_str(message), - } - } -} - -impl std::error::Error for Error {} - -impl From for Error { - fn from(err: io::Error) -> Self { - Self::Io(err) - } -} - -impl From for Error { - fn from(err: serde_json::Error) -> Self { - Self::Json(err) - } -} - -pub fn has_installed_server(language_id: &str) -> bool { - registry::for_language(language_id, ToolKind::Lsp).any(installer::is_installed) -} - -pub fn hover(request: HoverRequest) -> Result, Error> { - let mut errors = Vec::new(); - let mut attempted = false; - let mut answered = false; - - for entry in registry::for_language(&request.language_id, ToolKind::Lsp) { - if !installer::is_installed(entry) { - continue; - } - attempted = true; - - match session(entry, &request.root).and_then(|client| client.hover(entry, &request)) { - Ok(Some(contents)) => return Ok(Some(Hover { contents })), - Ok(None) => answered = true, - Err(err) => errors.push(format!("{}: {err}", entry.id)), - } - } - - if attempted && !answered && !errors.is_empty() { - return Err(Error::AllFailed(errors.join("; "))); - } - - Ok(None) -} - -pub fn completion( - request: CompletionRequest, -) -> Result, Error> { - let mut errors = Vec::new(); - let mut attempted = false; - let mut answered = false; - - for entry in registry::for_language(&request.language_id, ToolKind::Lsp) { - if !installer::is_installed(entry) { - continue; - } - attempted = true; - - match session(entry, &request.root).and_then(|client| client.completion(entry, &request)) { - Ok(Some(response)) => return Ok(Some(response)), - Ok(None) => answered = true, - Err(err) => errors.push(format!("{}: {err}", entry.id)), - } - } - - if attempted && !answered && !errors.is_empty() { - return Err(Error::AllFailed(errors.join("; "))); - } - - Ok(None) -} - -pub fn diagnostics(request: DiagnosticsRequest) -> Result { - let mut diagnostics = Vec::new(); - let mut errors = Vec::new(); - let mut attempted = false; - let mut answered = false; - let mut max_epoch = 0; - let mut all_fresh = true; - - for entry in registry::for_language(&request.language_id, ToolKind::Lsp) { - if !installer::is_installed(entry) { - continue; - } - attempted = true; - - match session(entry, &request.root).and_then(|client| client.diagnostics(entry, &request)) { - Ok(response) => { - answered = true; - max_epoch = max_epoch.max(response.epoch); - all_fresh &= response.fresh; - diagnostics.extend(response.diagnostics); - } - Err(err) => errors.push(format!("{}: {err}", entry.id)), - } - } - - if attempted && !answered && !errors.is_empty() { - return Err(Error::AllFailed(errors.join("; "))); - } - - Ok(DiagnosticsResponse { - diagnostics, - epoch: max_epoch, - fresh: answered && all_fresh, - }) -} - -pub fn did_save(request: SaveRequest) -> Result<(), Error> { - let mut errors = Vec::new(); - let mut attempted = false; - let mut saved = false; - - for entry in registry::for_language(&request.language_id, ToolKind::Lsp) { - if !installer::is_installed(entry) { - continue; - } - attempted = true; - - match session(entry, &request.root).and_then(|client| client.did_save(entry, &request)) { - Ok(()) => saved = true, - Err(err) => errors.push(format!("{}: {err}", entry.id)), - } - } - - if attempted && !saved && !errors.is_empty() { - return Err(Error::AllFailed(errors.join("; "))); - } - - Ok(()) -} - -#[derive(Clone, Eq, PartialEq, Hash)] -struct SessionKey { - server_id: &'static str, - root: PathBuf, -} - -static SESSIONS: OnceLock>> = OnceLock::new(); - -fn session(entry: &'static RegistryEntry, root: &Path) -> Result { - let root = canonical_or_original(root); - let key = SessionKey { - server_id: entry.id, - root: root.clone(), - }; - - if let Some(existing) = SESSIONS - .get_or_init(Default::default) - .lock() - .unwrap() - .get(&key) - .cloned() - { - return Ok(existing); - } - - let client = Client::start(entry, root)?; - let mut sessions = SESSIONS.get_or_init(Default::default).lock().unwrap(); - Ok(sessions.entry(key).or_insert_with(|| client).clone()) -} - -fn canonical_or_original(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - -#[derive(Clone)] -struct Client { - inner: Arc, -} - -struct ClientInner { - server_id: &'static str, - child: Mutex, - stdin: Mutex, - pending: Mutex>>, - documents: Mutex>, - sync_kind: Mutex, - diagnostics: Mutex>, - diagnostics_changed: Condvar, - next_id: AtomicU64, -} - -#[derive(Clone)] -struct DocumentState { - version: i32, - content: String, -} - -#[derive(Clone)] -struct DiagnosticState { - version: Option, - diagnostics: Vec, - epoch: u64, -} - -#[derive(Clone, Copy)] -struct DocumentSync { - version: i32, - changed: bool, -} - -impl Drop for ClientInner { - fn drop(&mut self) { - let _ = self.child.lock().unwrap().kill(); - } -} diff --git a/crates/pane_tree/Cargo.toml b/crates/pane_tree/Cargo.toml deleted file mode 100644 index 3d88c396..00000000 --- a/crates/pane_tree/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "pane_tree" -version = "0.4.9" -edition = "2024" - -[dependencies] -panes = { path = "../panes" } -tabs = { path = "../tabs" } diff --git a/crates/pane_tree/README.md b/crates/pane_tree/README.md deleted file mode 100644 index 9d77e8bc..00000000 --- a/crates/pane_tree/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# pane_tree - -Role: pane split tree and tab movement model. - -Owns: -- Tree of panes and split nodes. -- Pane, split, and tab id allocation. -- Active pane tracking. -- Tab movement between panes. -- Pane splitting, empty-pane collapse, and split resize ratios. - -Does Not Own: -- GPUI action wiring. -- File-editor-specific open behavior. -- Rendering. -- Persistence format. diff --git a/crates/pane_tree/src/lib.rs b/crates/pane_tree/src/lib.rs deleted file mode 100644 index 8c9f953a..00000000 --- a/crates/pane_tree/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[cfg(test)] -mod tests; - -include!("parts/types.rs"); -include!("parts/tabs.rs"); -include!("parts/splits.rs"); -include!("parts/helpers.rs"); diff --git a/crates/pane_tree/src/parts/helpers.rs b/crates/pane_tree/src/parts/helpers.rs deleted file mode 100644 index 2a6fb216..00000000 --- a/crates/pane_tree/src/parts/helpers.rs +++ /dev/null @@ -1,200 +0,0 @@ -impl PaneTree { - fn refresh_active_pane(&mut self, preferred: usize) { - if Self::find_pane(&self.root, preferred).is_some() { - self.active_pane_id = preferred; - return; - } - if Self::find_pane(&self.root, self.active_pane_id).is_some() { - return; - } - if let Some(fallback) = Self::first_pane_id(&self.root) { - self.active_pane_id = fallback; - } - } - - fn first_pane_id(node: &PaneNode) -> Option { - match node { - PaneNode::Leaf(pane) => Some(pane.id()), - PaneNode::Split { first, second, .. } => { - Self::first_pane_id(first).or_else(|| Self::first_pane_id(second)) - } - } - } - - fn pane_has_tab(node: &PaneNode, pane_id: usize, tab_id: usize) -> bool { - Self::find_pane(node, pane_id).is_some_and(|p| p.has_tab(tab_id)) - } - - fn biggest_pane_in(node: &PaneNode, weight: f32) -> Option<(usize, f32)> { - match node { - PaneNode::Leaf(pane) => Some((pane.id(), weight)), - PaneNode::Split { - ratio, - first, - second, - .. - } => { - let first_candidate = Self::biggest_pane_in(first, weight * ratio); - let second_candidate = Self::biggest_pane_in(second, weight * (1.0 - ratio)); - match (first_candidate, second_candidate) { - (Some(first), Some(second)) => { - Some(if first.1 >= second.1 { first } else { second }) - } - (first, second) => first.or(second), - } - } - } - } - - fn find_tab_in<'a>( - node: &'a PaneNode, - predicate: &mut impl FnMut(&Tab) -> bool, - ) -> Option<(usize, &'a Tab)> { - match node { - PaneNode::Leaf(pane) => pane - .tabs() - .iter() - .find(|tab| predicate(tab)) - .map(|tab| (pane.id(), tab)), - PaneNode::Split { first, second, .. } => { - Self::find_tab_in(first, predicate).or_else(|| Self::find_tab_in(second, predicate)) - } - } - } - - fn find_tab_mut_in(node: &mut PaneNode, tab_id: usize) -> Option<&mut Tab> { - match node { - PaneNode::Leaf(pane) => pane.tab_mut(tab_id), - PaneNode::Split { first, second, .. } => { - Self::find_tab_mut_in(first, tab_id) - .or_else(|| Self::find_tab_mut_in(second, tab_id)) - } - } - } - - fn find_pane(node: &PaneNode, pane_id: usize) -> Option<&Pane> { - match node { - PaneNode::Leaf(pane) if pane.id() == pane_id => Some(pane), - PaneNode::Leaf(_) => None, - PaneNode::Split { first, second, .. } => { - Self::find_pane(first, pane_id).or_else(|| Self::find_pane(second, pane_id)) - } - } - } - - fn find_pane_mut(node: &mut PaneNode, pane_id: usize) -> Option<&mut Pane> { - match node { - PaneNode::Leaf(pane) if pane.id() == pane_id => Some(pane), - PaneNode::Leaf(_) => None, - PaneNode::Split { first, second, .. } => { - Self::find_pane_mut(first, pane_id).or_else(|| Self::find_pane_mut(second, pane_id)) - } - } - } - - fn total_tabs_in(node: &PaneNode) -> usize { - match node { - PaneNode::Leaf(pane) => pane.tabs().len(), - PaneNode::Split { first, second, .. } => { - Self::total_tabs_in(first) + Self::total_tabs_in(second) - } - } - } - - fn find_split_ratio_mut(node: &mut PaneNode, split_id: usize) -> Option<&mut f32> { - match node { - PaneNode::Leaf(_) => None, - PaneNode::Split { - id, - ratio, - first, - second, - .. - } => { - if *id == split_id { - Some(ratio) - } else { - Self::find_split_ratio_mut(first, split_id) - .or_else(|| Self::find_split_ratio_mut(second, split_id)) - } - } - } - } - - fn split_leaf_with_tab( - node: &mut PaneNode, - pane_id: usize, - tab: Tab, - new_pane_id: usize, - new_split_id: usize, - drop_zone: DropZone, - ) -> bool { - match node { - PaneNode::Leaf(pane) if pane.id() == pane_id => { - let axis = match drop_zone { - DropZone::Left | DropZone::Right => SplitAxis::Row, - DropZone::Top | DropZone::Bottom => SplitAxis::Column, - DropZone::Center => return false, - }; - let new_pane = PaneNode::Leaf(Pane::new(new_pane_id, tab)); - let existing_pane = PaneNode::Leaf(pane.clone()); - - let (first, second) = match drop_zone { - DropZone::Left | DropZone::Top => (new_pane, existing_pane), - DropZone::Right | DropZone::Bottom => (existing_pane, new_pane), - DropZone::Center => return false, - }; - - *node = PaneNode::Split { - id: new_split_id, - axis, - ratio: 0.5, - first: Box::new(first), - second: Box::new(second), - }; - true - } - PaneNode::Leaf(_) => false, - PaneNode::Split { first, second, .. } => { - Self::split_leaf_with_tab( - first, - pane_id, - tab.clone(), - new_pane_id, - new_split_id, - drop_zone, - ) || Self::split_leaf_with_tab( - second, - pane_id, - tab, - new_pane_id, - new_split_id, - drop_zone, - ) - } - } - } - - fn collapse_empty_panes(node: &mut PaneNode) -> bool { - let replacement = match node { - PaneNode::Leaf(pane) => return pane.is_empty(), - PaneNode::Split { first, second, .. } => { - let first_empty = Self::collapse_empty_panes(first); - let second_empty = Self::collapse_empty_panes(second); - - match (first_empty, second_empty) { - (true, true) => return true, - (true, false) => Some((**second).clone()), - (false, true) => Some((**first).clone()), - (false, false) => None, - } - } - }; - - if let Some(replacement) = replacement { - *node = replacement; - } - - false - } -} diff --git a/crates/pane_tree/src/parts/splits.rs b/crates/pane_tree/src/parts/splits.rs deleted file mode 100644 index 53acbab9..00000000 --- a/crates/pane_tree/src/parts/splits.rs +++ /dev/null @@ -1,55 +0,0 @@ -impl PaneTree { - pub fn split_pane( - &mut self, - source_pane_id: usize, - tab_id: usize, - target_pane_id: usize, - drop_zone: DropZone, - ) -> bool { - if drop_zone == DropZone::Center || Self::total_tabs_in(&self.root) == 1 { - return false; - } - if !Self::pane_has_tab(&self.root, source_pane_id, tab_id) { - return false; - } - if Self::find_pane(&self.root, target_pane_id).is_none() { - return false; - } - if source_pane_id == target_pane_id { - let source_pane = Self::find_pane(&self.root, source_pane_id).unwrap(); - if source_pane.tabs().len() == 1 { - return false; - } - } - - let new_pane_id = self.next_pane_id; - let new_split_id = self.next_split_id; - - let source = Self::find_pane_mut(&mut self.root, source_pane_id).unwrap(); - let tab = source.take_tab(tab_id).unwrap(); - Self::split_leaf_with_tab( - &mut self.root, - target_pane_id, - tab, - new_pane_id, - new_split_id, - drop_zone, - ); - - self.next_pane_id += 1; - self.next_split_id += 1; - Self::collapse_empty_panes(&mut self.root); - self.active_pane_id = new_pane_id; - self.refresh_active_pane(new_pane_id); - true - } - - pub fn resize_split(&mut self, split_id: usize, ratio: f32) -> bool { - let Some(split_ratio) = Self::find_split_ratio_mut(&mut self.root, split_id) else { - return false; - }; - *split_ratio = ratio.clamp(0.15, 0.85); - true - } - -} diff --git a/crates/pane_tree/src/parts/tabs.rs b/crates/pane_tree/src/parts/tabs.rs deleted file mode 100644 index 71baf27d..00000000 --- a/crates/pane_tree/src/parts/tabs.rs +++ /dev/null @@ -1,303 +0,0 @@ -impl PaneTree { - pub fn new() -> Self { - Self { - root: PaneNode::Leaf(Pane::new(0, Tab::new(0, ®istry::BLANK))), - next_tab_id: 1, - next_pane_id: 1, - next_split_id: 1, - active_pane_id: 0, - } - } - - pub fn from_parts( - root: PaneNode, - next_tab_id: usize, - next_pane_id: usize, - next_split_id: usize, - ) -> Self { - let active_pane_id = Self::first_pane_id(&root).unwrap_or(0); - Self { - root, - next_tab_id, - next_pane_id, - next_split_id, - active_pane_id, - } - } - - pub fn root(&self) -> &PaneNode { - &self.root - } - - pub fn next_tab_id(&self) -> usize { - self.next_tab_id - } - - pub fn next_pane_id(&self) -> usize { - self.next_pane_id - } - - pub fn next_split_id(&self) -> usize { - self.next_split_id - } - - pub fn active_pane_id(&self) -> usize { - self.active_pane_id - } - - pub fn active_pane(&self) -> Option<&Pane> { - Self::find_pane(&self.root, self.active_pane_id) - } - - pub fn pane(&self, pane_id: usize) -> Option<&Pane> { - Self::find_pane(&self.root, pane_id) - } - - pub fn find_tab(&self, mut predicate: impl FnMut(&Tab) -> bool) -> Option<(usize, usize)> { - Self::find_tab_in(&self.root, &mut predicate).map(|(pane_id, tab)| (pane_id, tab.id)) - } - - pub fn set_tab_path(&mut self, tab_id: usize, path: Option) -> bool { - let Some(tab) = Self::find_tab_mut_in(&mut self.root, tab_id) else { - return false; - }; - if tab.path == path { - return false; - } - tab.path = path; - true - } - - pub fn total_tabs(&self) -> usize { - Self::total_tabs_in(&self.root) - } - - pub fn add_tab(&mut self, pane_id: usize, kind: &'static TabKind) -> bool { - self.append_new_tab(pane_id, |id| Tab::new(id, kind)) - .is_some() - } - - /// Pane id with the largest rendered area, weighted by accumulated split - /// ratios. Ties go to the first pane encountered in DFS order. - pub fn biggest_pane_id(&self) -> usize { - Self::biggest_pane_in(&self.root, 1.0) - .map(|(id, _)| id) - .unwrap_or(self.active_pane_id) - } - - pub fn append_new_tab( - &mut self, - pane_id: usize, - build_tab: impl FnOnce(usize) -> Tab, - ) -> Option<(usize, usize)> { - let tab_id = self.next_tab_id; - let pane = Self::find_pane_mut(&mut self.root, pane_id)?; - pane.add_tab(build_tab(tab_id)); - self.next_tab_id += 1; - self.active_pane_id = pane_id; - Some((pane_id, pane.tabs().len())) - } - - pub fn insert_new_tab_before( - &mut self, - pane_id: usize, - target_tab_id: usize, - build_tab: impl FnOnce(usize) -> Tab, - ) -> Option<(usize, usize)> { - let pane = Self::find_pane_mut(&mut self.root, pane_id)?; - if !pane.has_tab(target_tab_id) { - return None; - } - let tab_id = self.next_tab_id; - if !pane.insert_tab_before(build_tab(tab_id), target_tab_id) { - return None; - } - self.next_tab_id += 1; - self.active_pane_id = pane_id; - Some((pane_id, pane.tabs().len())) - } - - pub fn split_pane_with_new_tab( - &mut self, - target_pane_id: usize, - drop_zone: DropZone, - build_tab: impl FnOnce(usize) -> Tab, - ) -> Option<(usize, usize)> { - if drop_zone == DropZone::Center { - return None; - } - Self::find_pane(&self.root, target_pane_id)?; - - let new_pane_id = self.next_pane_id; - let new_split_id = self.next_split_id; - let tab_id = self.next_tab_id; - let tab = build_tab(tab_id); - - if !Self::split_leaf_with_tab( - &mut self.root, - target_pane_id, - tab, - new_pane_id, - new_split_id, - drop_zone, - ) { - return None; - } - - self.next_tab_id += 1; - self.next_pane_id += 1; - self.next_split_id += 1; - self.active_pane_id = new_pane_id; - Some((new_pane_id, 1)) - } - - pub fn add_tab_to_active(&mut self, kind: &'static TabKind) -> bool { - self.add_tab(self.active_pane_id, kind) - } - - pub fn focus_pane(&mut self, pane_id: usize) -> bool { - if Self::find_pane(&self.root, pane_id).is_none() { - return false; - } - if self.active_pane_id == pane_id { - return false; - } - self.active_pane_id = pane_id; - true - } - - pub fn close_active_tab(&mut self) -> bool { - let Some(pane) = self.active_pane() else { - return false; - }; - let tab_id = pane.active_tab(); - self.close_tab(self.active_pane_id, tab_id) - } - - pub fn replace_tab_kind( - &mut self, - pane_id: usize, - tab_id: usize, - kind: &'static TabKind, - ) -> bool { - let Some(pane) = Self::find_pane_mut(&mut self.root, pane_id) else { - return false; - }; - if !pane.replace_tab(tab_id, Tab::new(tab_id, kind)) { - return false; - } - self.active_pane_id = pane_id; - true - } - - pub fn select_tab(&mut self, pane_id: usize, tab_id: usize) -> bool { - let Some(pane) = Self::find_pane_mut(&mut self.root, pane_id) else { - return false; - }; - if !pane.select_tab(tab_id) { - return false; - } - self.active_pane_id = pane_id; - true - } - - pub fn close_tab(&mut self, pane_id: usize, tab_id: usize) -> bool { - if Self::total_tabs_in(&self.root) == 1 { - return false; - } - - let Some(pane) = Self::find_pane_mut(&mut self.root, pane_id) else { - return false; - }; - if pane.take_tab(tab_id).is_none() { - return false; - } - - Self::collapse_empty_panes(&mut self.root); - self.refresh_active_pane(pane_id); - true - } - - pub fn move_tab_before( - &mut self, - source_pane_id: usize, - tab_id: usize, - target_pane_id: usize, - target_tab_id: usize, - ) -> bool { - if source_pane_id == target_pane_id && tab_id == target_tab_id { - return false; - } - if !Self::pane_has_tab(&self.root, source_pane_id, tab_id) { - return false; - } - if !Self::pane_has_tab(&self.root, target_pane_id, target_tab_id) { - return false; - } - - let source = Self::find_pane_mut(&mut self.root, source_pane_id).unwrap(); - let tab = source.take_tab(tab_id).unwrap(); - let target = Self::find_pane_mut(&mut self.root, target_pane_id).unwrap(); - target.insert_tab_before(tab, target_tab_id); - Self::collapse_empty_panes(&mut self.root); - self.active_pane_id = target_pane_id; - self.refresh_active_pane(target_pane_id); - true - } - - pub fn move_tab_to_pane( - &mut self, - source_pane_id: usize, - tab_id: usize, - target_pane_id: usize, - ) -> bool { - if source_pane_id == target_pane_id { - return false; - } - if !Self::pane_has_tab(&self.root, source_pane_id, tab_id) { - return false; - } - if Self::find_pane(&self.root, target_pane_id).is_none() { - return false; - } - - let source = Self::find_pane_mut(&mut self.root, source_pane_id).unwrap(); - let tab = source.take_tab(tab_id).unwrap(); - let target = Self::find_pane_mut(&mut self.root, target_pane_id).unwrap(); - target.add_tab(tab); - Self::collapse_empty_panes(&mut self.root); - self.active_pane_id = target_pane_id; - self.refresh_active_pane(target_pane_id); - true - } - - pub fn move_tab_to_end( - &mut self, - source_pane_id: usize, - tab_id: usize, - target_pane_id: usize, - ) -> bool { - if !Self::pane_has_tab(&self.root, source_pane_id, tab_id) { - return false; - } - if Self::find_pane(&self.root, target_pane_id).is_none() { - return false; - } - if source_pane_id == target_pane_id { - let target_pane = Self::find_pane(&self.root, target_pane_id).unwrap(); - if target_pane.tabs().last().map(|t| t.id) == Some(tab_id) { - return false; - } - } - - let source = Self::find_pane_mut(&mut self.root, source_pane_id).unwrap(); - let tab = source.take_tab(tab_id).unwrap(); - let target = Self::find_pane_mut(&mut self.root, target_pane_id).unwrap(); - target.add_tab(tab); - Self::collapse_empty_panes(&mut self.root); - self.active_pane_id = target_pane_id; - self.refresh_active_pane(target_pane_id); - true - } - -} diff --git a/crates/pane_tree/src/parts/types.rs b/crates/pane_tree/src/parts/types.rs deleted file mode 100644 index 392cf306..00000000 --- a/crates/pane_tree/src/parts/types.rs +++ /dev/null @@ -1,43 +0,0 @@ -use panes::Pane; -use tabs::{Tab, TabKind, registry}; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum SplitAxis { - Row, - Column, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum DropZone { - Center, - Left, - Right, - Top, - Bottom, -} - -#[derive(Clone)] -pub enum PaneNode { - Leaf(Pane), - Split { - id: usize, - axis: SplitAxis, - ratio: f32, - first: Box, - second: Box, - }, -} - -pub struct PaneTree { - root: PaneNode, - next_tab_id: usize, - next_pane_id: usize, - next_split_id: usize, - active_pane_id: usize, -} - -impl Default for PaneTree { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/pane_tree/src/tests/mod.rs b/crates/pane_tree/src/tests/mod.rs deleted file mode 100644 index 0c89fd56..00000000 --- a/crates/pane_tree/src/tests/mod.rs +++ /dev/null @@ -1,224 +0,0 @@ -use super::*; -use tabs::registry; - -fn leaf(node: &PaneNode) -> &Pane { - match node { - PaneNode::Leaf(p) => p, - PaneNode::Split { .. } => panic!("expected leaf"), - } -} - -fn split_children(node: &PaneNode) -> (&PaneNode, &PaneNode) { - match node { - PaneNode::Split { first, second, .. } => (first, second), - PaneNode::Leaf(_) => panic!("expected split"), - } -} - -fn tab_ids(pane: &Pane) -> Vec { - pane.tabs().iter().map(|t| t.id).collect() -} - -#[test] -fn new_tree_has_single_pane_with_one_tab() { - let tree = PaneTree::new(); - assert_eq!(tree.total_tabs(), 1); - let pane = leaf(tree.root()); - assert_eq!(pane.id(), 0); - assert_eq!(pane.active_tab(), 0); - assert_eq!(tab_ids(pane), vec![0]); -} - -#[test] -fn add_tab_to_known_pane_appends_and_activates() { - let mut tree = PaneTree::new(); - assert!(tree.add_tab(0, ®istry::BLANK)); - let pane = leaf(tree.root()); - assert_eq!(tab_ids(pane), vec![0, 1]); - assert_eq!(pane.active_tab(), 1); -} - -#[test] -fn add_tab_unknown_pane_does_not_consume_id() { - let mut tree = PaneTree::new(); - assert!(!tree.add_tab(99, ®istry::BLANK)); - assert!(tree.add_tab(0, ®istry::BLANK)); - let pane = leaf(tree.root()); - assert_eq!(pane.tabs().last().unwrap().id, 1); -} - -#[test] -fn replace_tab_kind_swaps_in_place_keeping_id_and_position() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.replace_tab_kind(0, 1, ®istry::TERMINAL)); - let pane = leaf(tree.root()); - assert_eq!(tab_ids(pane), vec![0, 1, 2]); - assert_eq!(pane.tabs()[1].kind.as_str(), "terminal"); - assert_eq!(pane.tabs()[0].kind.as_str(), "blank"); -} - -#[test] -fn replace_tab_kind_unknown_pane_or_tab_returns_false() { - let mut tree = PaneTree::new(); - assert!(!tree.replace_tab_kind(99, 0, ®istry::TERMINAL)); - assert!(!tree.replace_tab_kind(0, 99, ®istry::TERMINAL)); -} - -#[test] -fn set_tab_path_updates_existing_tab() { - let mut tree = PaneTree::new(); - let path = std::path::PathBuf::from("/tmp"); - - assert!(tree.set_tab_path(0, Some(path.clone()))); - assert!(!tree.set_tab_path(0, Some(path.clone()))); - assert!(!tree.set_tab_path(99, Some(path.clone()))); - assert_eq!(leaf(tree.root()).tabs()[0].path.as_ref(), Some(&path)); -} - -#[test] -fn select_tab_validates_membership() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.select_tab(0, 0)); - assert_eq!(leaf(tree.root()).active_tab(), 0); - assert!(!tree.select_tab(0, 999)); - assert_eq!(leaf(tree.root()).active_tab(), 0); - assert!(!tree.select_tab(99, 0)); -} - -#[test] -fn close_last_tab_is_rejected() { - let mut tree = PaneTree::new(); - assert!(!tree.close_tab(0, 0)); - assert_eq!(tree.total_tabs(), 1); -} - -#[test] -fn close_tab_keeps_remaining_tabs() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.close_tab(0, 1)); - assert_eq!(tab_ids(leaf(tree.root())), vec![0]); -} - -#[test] -fn close_tab_collapses_empty_split_pane() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.split_pane(0, 1, 0, DropZone::Right)); - assert!(matches!(tree.root(), PaneNode::Split { .. })); - assert!(tree.close_tab(1, 1)); - assert!(matches!(tree.root(), PaneNode::Leaf(_))); - assert_eq!(tree.total_tabs(), 1); -} - -#[test] -fn move_tab_before_reorders_within_pane() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.move_tab_before(0, 2, 0, 0)); - assert_eq!(tab_ids(leaf(tree.root())), vec![2, 0, 1]); -} - -#[test] -fn move_tab_before_self_is_rejected() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(!tree.move_tab_before(0, 0, 0, 0)); -} - -#[test] -fn move_tab_before_unknown_target_leaves_state_unchanged() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - let before = tab_ids(leaf(tree.root())); - let active_before = leaf(tree.root()).active_tab(); - assert!(!tree.move_tab_before(0, 1, 0, 999)); - assert_eq!(tab_ids(leaf(tree.root())), before); - assert_eq!(leaf(tree.root()).active_tab(), active_before); -} - -#[test] -fn move_tab_to_pane_moves_across_split() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.split_pane(0, 1, 0, DropZone::Right)); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.move_tab_to_pane(0, 2, 1)); - - let (first, second) = split_children(tree.root()); - assert_eq!(tab_ids(leaf(first)), vec![0]); - assert_eq!(tab_ids(leaf(second)), vec![1, 2]); -} - -#[test] -fn move_tab_to_pane_same_pane_is_rejected() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(!tree.move_tab_to_pane(0, 1, 0)); -} - -#[test] -fn split_pane_with_only_tab_is_rejected() { - let mut tree = PaneTree::new(); - assert!(!tree.split_pane(0, 0, 0, DropZone::Right)); - assert!(matches!(tree.root(), PaneNode::Leaf(_))); -} - -#[test] -fn split_pane_to_self_with_one_tab_is_rejected() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.split_pane(0, 1, 0, DropZone::Right)); - assert!(!tree.split_pane(1, 1, 1, DropZone::Right)); -} - -#[test] -fn split_pane_assigns_axis_from_drop_zone() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - assert!(tree.split_pane(0, 1, 0, DropZone::Bottom)); - assert!(matches!( - tree.root(), - PaneNode::Split { - axis: SplitAxis::Column, - .. - } - )); -} - -#[test] -fn split_pane_unknown_target_preserves_ids_and_tabs() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - let next_pane = tree.next_pane_id; - let next_split = tree.next_split_id; - assert!(!tree.split_pane(0, 1, 99, DropZone::Right)); - assert_eq!(tree.next_pane_id, next_pane); - assert_eq!(tree.next_split_id, next_split); - assert_eq!(tree.total_tabs(), 2); - assert_eq!(tab_ids(leaf(tree.root())), vec![0, 1]); -} - -#[test] -fn resize_split_clamps_to_range() { - let mut tree = PaneTree::new(); - tree.add_tab(0, ®istry::BLANK); - tree.split_pane(0, 1, 0, DropZone::Right); - let split_id = match tree.root() { - PaneNode::Split { id, .. } => *id, - PaneNode::Leaf(_) => panic!("expected split"), - }; - assert!(tree.resize_split(split_id, 0.99)); - if let PaneNode::Split { ratio, .. } = tree.root() { - assert!((*ratio - 0.85).abs() < f32::EPSILON); - } - assert!(tree.resize_split(split_id, 0.01)); - if let PaneNode::Split { ratio, .. } = tree.root() { - assert!((*ratio - 0.15).abs() < f32::EPSILON); - } - assert!(!tree.resize_split(999, 0.5)); -} diff --git a/crates/panes/Cargo.toml b/crates/panes/Cargo.toml deleted file mode 100644 index b8c26a97..00000000 --- a/crates/panes/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "panes" -version = "0.4.9" -edition = "2024" - -[dependencies] -tabs = { path = "../tabs" } diff --git a/crates/panes/README.md b/crates/panes/README.md deleted file mode 100644 index 6b15ef08..00000000 --- a/crates/panes/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# panes - -Role: single-pane tab collection model. - -Owns: -- Pane identity. -- Ordered tabs within one pane. -- Active tab selection inside one pane. -- Tab insertion, replacement, selection, and removal within one pane. - -Does Not Own: -- Split tree layout. -- Tab kind semantics. -- Rendering or drag-and-drop UI. -- Persistence. diff --git a/crates/panes/src/lib.rs b/crates/panes/src/lib.rs deleted file mode 100644 index a1168f81..00000000 --- a/crates/panes/src/lib.rs +++ /dev/null @@ -1,90 +0,0 @@ -use tabs::Tab; - -#[derive(Clone)] -pub struct Pane { - id: usize, - tabs: Vec, - active_tab: usize, -} - -impl Pane { - pub fn new(id: usize, initial_tab: Tab) -> Self { - Self { - id, - active_tab: initial_tab.id, - tabs: vec![initial_tab], - } - } - - pub fn from_parts(id: usize, tabs: Vec, active_tab: usize) -> Self { - Self { - id, - tabs, - active_tab, - } - } - - pub fn id(&self) -> usize { - self.id - } - - pub fn tabs(&self) -> &[Tab] { - &self.tabs - } - - pub fn tab_mut(&mut self, tab_id: usize) -> Option<&mut Tab> { - self.tabs.iter_mut().find(|tab| tab.id == tab_id) - } - - pub fn active_tab(&self) -> usize { - self.active_tab - } - - pub fn has_tab(&self, tab_id: usize) -> bool { - self.tabs.iter().any(|t| t.id == tab_id) - } - - pub fn add_tab(&mut self, tab: Tab) { - self.active_tab = tab.id; - self.tabs.push(tab); - } - - pub fn insert_tab_before(&mut self, tab: Tab, before_tab_id: usize) -> bool { - let Some(index) = self.tabs.iter().position(|t| t.id == before_tab_id) else { - return false; - }; - self.active_tab = tab.id; - self.tabs.insert(index, tab); - true - } - - pub fn replace_tab(&mut self, tab_id: usize, replacement: Tab) -> bool { - let Some(index) = self.tabs.iter().position(|t| t.id == tab_id) else { - return false; - }; - self.tabs[index] = replacement; - true - } - - pub fn select_tab(&mut self, tab_id: usize) -> bool { - if !self.has_tab(tab_id) { - return false; - } - self.active_tab = tab_id; - true - } - - pub fn take_tab(&mut self, tab_id: usize) -> Option { - let index = self.tabs.iter().position(|t| t.id == tab_id)?; - let tab = self.tabs.remove(index); - if self.active_tab == tab_id && !self.tabs.is_empty() { - let next = index.saturating_sub(1).min(self.tabs.len() - 1); - self.active_tab = self.tabs[next].id; - } - Some(tab) - } - - pub fn is_empty(&self) -> bool { - self.tabs.is_empty() - } -} diff --git a/crates/persistence/Cargo.toml b/crates/persistence/Cargo.toml deleted file mode 100644 index 3f7f69aa..00000000 --- a/crates/persistence/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "persistence" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -rusqlite = { workspace = true } -storage = { path = "../storage" } -workspace = { path = "../workspace" } -pane_tree = { path = "../pane_tree" } -panes = { path = "../panes" } -tabs = { path = "../tabs" } diff --git a/crates/persistence/README.md b/crates/persistence/README.md deleted file mode 100644 index 575bd31b..00000000 --- a/crates/persistence/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# persistence - -Role: local session persistence. - -Owns: -- SQLite connection management for session data. -- Schema migrations for workspace, pane tree, tabs, and window bounds. -- Loading and saving workspace manager state. -- Loading and saving window bounds. - -Does Not Own: -- Runtime mutation policy. -- Settings persistence. -- UI state that should remain transient. diff --git a/crates/persistence/src/db.rs b/crates/persistence/src/db.rs deleted file mode 100644 index 943039ec..00000000 --- a/crates/persistence/src/db.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::sync::{Mutex, OnceLock}; - -use rusqlite::{Connection, Result}; -use storage::{Database, with_connection}; - -const MIGRATIONS: &[&str] = &[ - include_str!("migrations/001_initial.sql"), - include_str!("migrations/002_tab_path.sql"), - include_str!("migrations/003_file_tree_expanded_dirs.sql"), -]; - -static CONN: OnceLock>> = OnceLock::new(); - -const DATABASE: Database = Database { - file_name: "data.db", - label: "session", - migrations: MIGRATIONS, - foreign_keys: true, -}; - -pub fn with(f: F) -> Option -where - F: FnOnce(&mut Connection) -> Result, -{ - with_connection(&CONN, DATABASE, f) -} diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs deleted file mode 100644 index c30c3a0e..00000000 --- a/crates/persistence/src/lib.rs +++ /dev/null @@ -1,434 +0,0 @@ -mod db; - -use std::path::PathBuf; - -use gpui::{Bounds, Point, Size, WindowBounds, px}; -use rusqlite::types::Type; -use rusqlite::{Connection, OptionalExtension, Result, Transaction, params, params_from_iter}; - -use pane_tree::{PaneNode, PaneTree, SplitAxis}; -use panes::Pane; -use tabs::Tab; -use workspace::{Workspace, WorkspaceManager}; - -pub fn load() -> WorkspaceManager { - db::with(load_inner) - .flatten() - .unwrap_or_else(WorkspaceManager::new) -} - -pub fn save_session(manager: &WorkspaceManager) { - db::with(|conn| save_session_inner(conn, manager)); -} - -pub fn save_workspace(workspace: &Workspace) { - db::with(|conn| save_workspace_inner(conn, workspace)); -} - -pub fn load_window_bounds() -> Option { - db::with(load_window_inner).flatten() -} - -pub fn save_window_bounds(bounds: WindowBounds) { - db::with(|conn| save_window_inner(conn, bounds)); -} - -fn load_inner(conn: &mut Connection) -> Result> { - let session: Option<(Option, i64)> = conn - .query_row( - "SELECT active_workspace_id, next_workspace_id FROM session WHERE id = 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - let Some((active_raw, next_raw)) = session else { - return Ok(None); - }; - - let workspace_rows = read_workspace_rows(conn)?; - let mut workspaces = Vec::with_capacity(workspace_rows.len()); - for row in workspace_rows { - let pane_tree = load_pane_tree( - conn, - row.id, - row.next_tab_id, - row.next_pane_id, - row.next_split_id, - )?; - workspaces.push(Workspace { - id: row.id, - path: PathBuf::from(row.path), - name: row.name, - pane_tree, - file_tree_expanded_dirs: load_file_tree_expanded_dirs(conn, row.id)?, - }); - } - - let active = active_raw - .map(|x| x as usize) - .filter(|id| workspaces.iter().any(|w| w.id == *id)) - .or_else(|| workspaces.first().map(|w| w.id)); - - Ok(Some(WorkspaceManager::from_parts( - workspaces, - active, - next_raw as usize, - ))) -} - -struct WorkspaceRow { - id: usize, - path: String, - name: String, - next_tab_id: usize, - next_pane_id: usize, - next_split_id: usize, -} - -fn read_workspace_rows(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, path, name, next_tab_id, next_pane_id, next_split_id - FROM workspaces ORDER BY position ASC", - )?; - let rows = stmt.query_map([], |row| { - Ok(WorkspaceRow { - id: row.get::<_, i64>(0)? as usize, - path: row.get(1)?, - name: row.get(2)?, - next_tab_id: row.get::<_, i64>(3)? as usize, - next_pane_id: row.get::<_, i64>(4)? as usize, - next_split_id: row.get::<_, i64>(5)? as usize, - }) - })?; - rows.collect() -} - -struct NodeRow { - id: i64, - parent_id: Option, - position: Option, - kind: String, - split_id: Option, - axis: Option, - ratio: Option, - pane_id: Option, - active_tab_id: Option, -} - -fn load_pane_tree( - conn: &Connection, - workspace_id: usize, - next_tab_id: usize, - next_pane_id: usize, - next_split_id: usize, -) -> Result { - let mut stmt = conn.prepare( - "SELECT id, parent_id, position, kind, split_id, axis, ratio, pane_id, active_tab_id - FROM pane_nodes WHERE workspace_id = ?", - )?; - let nodes: Vec = stmt - .query_map([workspace_id as i64], |row| { - Ok(NodeRow { - id: row.get(0)?, - parent_id: row.get(1)?, - position: row.get(2)?, - kind: row.get(3)?, - split_id: row.get(4)?, - axis: row.get(5)?, - ratio: row.get(6)?, - pane_id: row.get(7)?, - active_tab_id: row.get(8)?, - }) - })? - .collect::>()?; - drop(stmt); - - let Some(root) = nodes.iter().find(|n| n.parent_id.is_none()) else { - return Ok(PaneTree::new()); - }; - let root_node = build_node(conn, &nodes, root)?; - Ok(PaneTree::from_parts( - root_node, - next_tab_id, - next_pane_id, - next_split_id, - )) -} - -fn build_node(conn: &Connection, nodes: &[NodeRow], node: &NodeRow) -> Result { - match node.kind.as_str() { - "leaf" => { - let pane_id = node.pane_id.ok_or_else(|| bad("leaf missing pane_id"))? as usize; - let active_tab = - node.active_tab_id - .ok_or_else(|| bad("leaf missing active_tab_id"))? as usize; - let tabs = load_tabs(conn, node.id)?; - Ok(PaneNode::Leaf(Pane::from_parts(pane_id, tabs, active_tab))) - } - "split" => { - let split_id = node.split_id.ok_or_else(|| bad("split missing split_id"))? as usize; - let axis = match node.axis.as_deref() { - Some("row") => SplitAxis::Row, - Some("column") => SplitAxis::Column, - _ => return Err(bad("split has invalid axis")), - }; - let ratio = node.ratio.ok_or_else(|| bad("split missing ratio"))? as f32; - let mut children: Vec<&NodeRow> = nodes - .iter() - .filter(|n| n.parent_id == Some(node.id)) - .collect(); - children.sort_by_key(|n| n.position.unwrap_or(0)); - if children.len() != 2 { - return Err(bad("split must have two children")); - } - let first = build_node(conn, nodes, children[0])?; - let second = build_node(conn, nodes, children[1])?; - Ok(PaneNode::Split { - id: split_id, - axis, - ratio, - first: Box::new(first), - second: Box::new(second), - }) - } - _ => Err(bad("unknown pane_node kind")), - } -} - -fn load_tabs(conn: &Connection, pane_node_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT tab_id, kind, title, path FROM tabs WHERE pane_node_id = ? ORDER BY position ASC", - )?; - let rows = stmt.query_map([pane_node_id], |row| { - let id: i64 = row.get(0)?; - let kind: String = row.get(1)?; - let title: Option = row.get(2)?; - let path: Option = row.get(3)?; - Ok(Tab { - id: id as usize, - kind, - title, - path: path.map(PathBuf::from), - }) - })?; - rows.collect() -} - -fn load_file_tree_expanded_dirs(conn: &Connection, workspace_id: usize) -> Result> { - let mut stmt = conn.prepare( - "SELECT path FROM file_tree_expanded_dirs WHERE workspace_id = ? ORDER BY path ASC", - )?; - let rows = stmt.query_map([workspace_id as i64], |row| { - let path: String = row.get(0)?; - Ok(PathBuf::from(path)) - })?; - rows.collect() -} - -fn save_session_inner(conn: &mut Connection, manager: &WorkspaceManager) -> Result<()> { - let tx = conn.transaction()?; - - let kept: Vec = manager.workspaces().iter().map(|w| w.id as i64).collect(); - if kept.is_empty() { - tx.execute("DELETE FROM workspaces", [])?; - } else { - let placeholders = vec!["?"; kept.len()].join(","); - let sql = format!("DELETE FROM workspaces WHERE id NOT IN ({placeholders})"); - tx.execute(&sql, params_from_iter(kept.iter()))?; - } - - for (pos, ws) in manager.workspaces().iter().enumerate() { - tx.execute( - "UPDATE workspaces SET position = ?1 WHERE id = ?2", - params![pos as i64, ws.id as i64], - )?; - } - - tx.execute( - "INSERT INTO session (id, active_workspace_id, next_workspace_id) VALUES (1, ?1, ?2) - ON CONFLICT(id) DO UPDATE SET - active_workspace_id = excluded.active_workspace_id, - next_workspace_id = excluded.next_workspace_id", - params![ - manager.active_id().map(|x| x as i64), - manager.next_id() as i64 - ], - )?; - - tx.commit() -} - -fn save_workspace_inner(conn: &mut Connection, workspace: &Workspace) -> Result<()> { - let tx = conn.transaction()?; - tx.execute( - "INSERT INTO workspaces (id, position, path, name, next_tab_id, next_pane_id, next_split_id) - VALUES (?1, COALESCE((SELECT MAX(position) FROM workspaces), -1) + 1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - path = excluded.path, - name = excluded.name, - next_tab_id = excluded.next_tab_id, - next_pane_id = excluded.next_pane_id, - next_split_id = excluded.next_split_id", - params![ - workspace.id as i64, - workspace.path.to_string_lossy(), - workspace.name.as_str(), - workspace.pane_tree.next_tab_id() as i64, - workspace.pane_tree.next_pane_id() as i64, - workspace.pane_tree.next_split_id() as i64, - ], - )?; - tx.execute( - "DELETE FROM pane_nodes WHERE workspace_id = ?1", - params![workspace.id as i64], - )?; - write_node(&tx, workspace.id, None, None, workspace.pane_tree.root())?; - tx.execute( - "DELETE FROM file_tree_expanded_dirs WHERE workspace_id = ?1", - params![workspace.id as i64], - )?; - for path in &workspace.file_tree_expanded_dirs { - tx.execute( - "INSERT OR IGNORE INTO file_tree_expanded_dirs (workspace_id, path) VALUES (?1, ?2)", - params![workspace.id as i64, path.to_string_lossy()], - )?; - } - tx.commit() -} - -fn write_node( - tx: &Transaction, - workspace_id: usize, - parent_id: Option, - position: Option, - node: &PaneNode, -) -> Result<()> { - match node { - PaneNode::Leaf(pane) => { - tx.execute( - "INSERT INTO pane_nodes - (workspace_id, parent_id, position, kind, pane_id, active_tab_id) - VALUES (?1, ?2, ?3, 'leaf', ?4, ?5)", - params![ - workspace_id as i64, - parent_id, - position, - pane.id() as i64, - pane.active_tab() as i64, - ], - )?; - let pane_node_id = tx.last_insert_rowid(); - for (i, tab) in pane.tabs().iter().enumerate() { - tx.execute( - "INSERT INTO tabs (pane_node_id, position, tab_id, kind, title, path) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - pane_node_id, - i as i64, - tab.id as i64, - tab.kind.as_str(), - tab.title.as_deref(), - tab.path.as_ref().map(|p| p.to_string_lossy().into_owned()), - ], - )?; - } - } - PaneNode::Split { - id, - axis, - ratio, - first, - second, - } => { - let axis_str = match axis { - SplitAxis::Row => "row", - SplitAxis::Column => "column", - }; - tx.execute( - "INSERT INTO pane_nodes - (workspace_id, parent_id, position, kind, split_id, axis, ratio) - VALUES (?1, ?2, ?3, 'split', ?4, ?5, ?6)", - params![ - workspace_id as i64, - parent_id, - position, - *id as i64, - axis_str, - *ratio as f64, - ], - )?; - let pane_node_id = tx.last_insert_rowid(); - write_node(tx, workspace_id, Some(pane_node_id), Some(0), first)?; - write_node(tx, workspace_id, Some(pane_node_id), Some(1), second)?; - } - } - Ok(()) -} - -fn load_window_inner(conn: &mut Connection) -> Result> { - let row: Option<(String, f64, f64, f64, f64)> = conn - .query_row( - "SELECT state, origin_x, origin_y, width, height FROM window WHERE id = 1", - [], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, - ) - .optional()?; - let Some((state, ox, oy, w, h)) = row else { - return Ok(None); - }; - let bounds = Bounds { - origin: Point { - x: px(ox as f32), - y: px(oy as f32), - }, - size: Size { - width: px(w as f32), - height: px(h as f32), - }, - }; - let bounds = match state.as_str() { - "windowed" => WindowBounds::Windowed(bounds), - "maximized" => WindowBounds::Maximized(bounds), - "fullscreen" => WindowBounds::Fullscreen(bounds), - _ => return Ok(None), - }; - Ok(Some(bounds)) -} - -fn save_window_inner(conn: &mut Connection, bounds: WindowBounds) -> Result<()> { - let (state, b) = match bounds { - WindowBounds::Windowed(b) => ("windowed", b), - WindowBounds::Maximized(b) => ("maximized", b), - WindowBounds::Fullscreen(b) => ("fullscreen", b), - }; - conn.execute( - "INSERT INTO window (id, state, origin_x, origin_y, width, height) - VALUES (1, ?1, ?2, ?3, ?4, ?5) - ON CONFLICT(id) DO UPDATE SET - state = excluded.state, - origin_x = excluded.origin_x, - origin_y = excluded.origin_y, - width = excluded.width, - height = excluded.height", - params![ - state, - f32::from(b.origin.x) as f64, - f32::from(b.origin.y) as f64, - f32::from(b.size.width) as f64, - f32::from(b.size.height) as f64, - ], - )?; - Ok(()) -} - -fn bad(msg: &str) -> rusqlite::Error { - rusqlite::Error::FromSqlConversionFailure(0, Type::Null, msg.into()) -} diff --git a/crates/persistence/src/migrations/001_initial.sql b/crates/persistence/src/migrations/001_initial.sql deleted file mode 100644 index 1cbe3c84..00000000 --- a/crates/persistence/src/migrations/001_initial.sql +++ /dev/null @@ -1,50 +0,0 @@ -CREATE TABLE session ( - id INTEGER PRIMARY KEY CHECK (id = 1), - active_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, - next_workspace_id INTEGER NOT NULL -); - -CREATE TABLE window ( - id INTEGER PRIMARY KEY CHECK (id = 1), - state TEXT NOT NULL CHECK (state IN ('windowed', 'maximized', 'fullscreen')), - origin_x REAL NOT NULL, - origin_y REAL NOT NULL, - width REAL NOT NULL, - height REAL NOT NULL -); - -CREATE TABLE workspaces ( - id INTEGER PRIMARY KEY, - position INTEGER NOT NULL, - path TEXT NOT NULL, - name TEXT NOT NULL, - next_tab_id INTEGER NOT NULL, - next_pane_id INTEGER NOT NULL, - next_split_id INTEGER NOT NULL -); - -CREATE TABLE pane_nodes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - parent_id INTEGER REFERENCES pane_nodes(id) ON DELETE CASCADE, - position INTEGER, - kind TEXT NOT NULL CHECK (kind IN ('leaf', 'split')), - split_id INTEGER, - axis TEXT CHECK (axis IN ('row', 'column')), - ratio REAL, - pane_id INTEGER, - active_tab_id INTEGER -); - -CREATE INDEX pane_nodes_by_parent ON pane_nodes(workspace_id, parent_id); - -CREATE TABLE tabs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pane_node_id INTEGER NOT NULL REFERENCES pane_nodes(id) ON DELETE CASCADE, - position INTEGER NOT NULL, - tab_id INTEGER NOT NULL, - kind TEXT NOT NULL, - title TEXT -); - -CREATE INDEX tabs_by_pane ON tabs(pane_node_id, position); diff --git a/crates/persistence/src/migrations/002_tab_path.sql b/crates/persistence/src/migrations/002_tab_path.sql deleted file mode 100644 index 0aba05f3..00000000 --- a/crates/persistence/src/migrations/002_tab_path.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE tabs ADD COLUMN path TEXT; diff --git a/crates/persistence/src/migrations/003_file_tree_expanded_dirs.sql b/crates/persistence/src/migrations/003_file_tree_expanded_dirs.sql deleted file mode 100644 index 5fec3ad9..00000000 --- a/crates/persistence/src/migrations/003_file_tree_expanded_dirs.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE file_tree_expanded_dirs ( - workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - path TEXT NOT NULL, - PRIMARY KEY (workspace_id, path) -); diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml deleted file mode 100644 index 6a869b70..00000000 --- a/crates/registry/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "registry" -version = "0.4.9" -edition = "2024" - -[dependencies] - diff --git a/crates/registry/README.md b/crates/registry/README.md deleted file mode 100644 index 95e18202..00000000 --- a/crates/registry/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# registry - -Role: static external tool registry. - -Owns: -- Tool metadata for LSPs, formatters, and linters. -- Language-to-tool associations. -- Tool install source metadata. -- Tool launch specifications. - -Does Not Own: -- Installing tools. -- Running tools. -- Settings or installer UI. diff --git a/crates/registry/src/entries.rs b/crates/registry/src/entries.rs deleted file mode 100644 index e6df5ea7..00000000 --- a/crates/registry/src/entries.rs +++ /dev/null @@ -1,530 +0,0 @@ -use super::{GithubAsset, InstallSource, LaunchSpec, RegistryEntry, Target, ToolKind}; - -pub(super) static ENTRIES: &[RegistryEntry] = &[ - // ─── LSPs (npm) ─────────────────────────────────────────────────────── - RegistryEntry { - id: "typescript-language-server", - kinds: &[ToolKind::Lsp], - languages: &[ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", - ], - install: InstallSource::Npm { - package: "typescript-language-server", - bin: "typescript-language-server", - extra_packages: &["typescript"], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "tailwindcss-language-server", - kinds: &[ToolKind::Lsp], - languages: &[ - "html", - "css", - "javascript", - "javascriptreact", - "typescript", - "typescriptreact", - "vue", - "svelte", - "astro", - ], - install: InstallSource::Npm { - package: "@tailwindcss/language-server", - bin: "tailwindcss-language-server", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "vue-language-server", - kinds: &[ToolKind::Lsp], - languages: &["vue"], - install: InstallSource::Npm { - package: "@vue/language-server", - bin: "vue-language-server", - extra_packages: &["typescript"], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "svelte-language-server", - kinds: &[ToolKind::Lsp], - languages: &["svelte"], - install: InstallSource::Npm { - package: "svelte-language-server", - bin: "svelteserver", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "astro-language-server", - kinds: &[ToolKind::Lsp], - languages: &["astro"], - install: InstallSource::Npm { - package: "@astrojs/language-server", - bin: "astro-ls", - extra_packages: &["typescript"], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "yaml-language-server", - kinds: &[ToolKind::Lsp], - languages: &["yaml"], - install: InstallSource::Npm { - package: "yaml-language-server", - bin: "yaml-language-server", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "bash-language-server", - kinds: &[ToolKind::Lsp], - languages: &["shellscript"], - install: InstallSource::Npm { - package: "bash-language-server", - bin: "bash-language-server", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["start"], - env: &[], - }, - }, - RegistryEntry { - id: "dockerfile-language-server", - kinds: &[ToolKind::Lsp], - languages: &["dockerfile"], - install: InstallSource::Npm { - package: "dockerfile-language-server-nodejs", - bin: "docker-langserver", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "pyright", - kinds: &[ToolKind::Lsp], - languages: &["python"], - install: InstallSource::Npm { - package: "pyright", - bin: "pyright-langserver", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "emmet-ls", - kinds: &[ToolKind::Lsp], - languages: &["html", "css", "scss", "less"], - install: InstallSource::Npm { - package: "emmet-ls", - bin: "emmet-ls", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &["--stdio"], - env: &[], - }, - }, - // ─── LSPs (pip) ─────────────────────────────────────────────────────── - RegistryEntry { - id: "python-lsp-server", - kinds: &[ToolKind::Lsp], - languages: &["python"], - install: InstallSource::Pip { - package: "python-lsp-server", - bin: "pylsp", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── LSPs (cargo) ───────────────────────────────────────────────────── - RegistryEntry { - id: "taplo", - kinds: &[ToolKind::Lsp, ToolKind::Formatter], - languages: &["toml"], - install: InstallSource::Cargo { - crate_name: "taplo-cli", - bin: "taplo", - }, - launch: LaunchSpec { - args: &["lsp", "stdio"], - env: &[], - }, - }, - RegistryEntry { - id: "gdeye", - kinds: &[ToolKind::Lsp], - languages: &["gdscript"], - install: InstallSource::Cargo { - crate_name: "gdeye", - bin: "gdeye", - }, - launch: LaunchSpec { - args: &["lsp"], - env: &[], - }, - }, - // ─── LSPs (go) ──────────────────────────────────────────────────────── - RegistryEntry { - id: "gopls", - kinds: &[ToolKind::Lsp], - languages: &["go"], - install: InstallSource::Go { - module: "golang.org/x/tools/gopls", - bin: "gopls", - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── LSPs (github release) ──────────────────────────────────────────── - RegistryEntry { - id: "rust-analyzer", - kinds: &[ToolKind::Lsp], - languages: &["rust"], - install: InstallSource::GithubRelease { - repo: "rust-lang/rust-analyzer", - assets: &[ - GithubAsset { - target: Target::LinuxX64Gnu, - file: "rust-analyzer-x86_64-unknown-linux-gnu.gz", - bin: "rust-analyzer-x86_64-unknown-linux-gnu", - }, - GithubAsset { - target: Target::LinuxArm64Gnu, - file: "rust-analyzer-aarch64-unknown-linux-gnu.gz", - bin: "rust-analyzer-aarch64-unknown-linux-gnu", - }, - GithubAsset { - target: Target::LinuxX64Musl, - file: "rust-analyzer-x86_64-unknown-linux-musl.gz", - bin: "rust-analyzer-x86_64-unknown-linux-musl", - }, - GithubAsset { - target: Target::DarwinX64, - file: "rust-analyzer-x86_64-apple-darwin.gz", - bin: "rust-analyzer-x86_64-apple-darwin", - }, - GithubAsset { - target: Target::DarwinArm64, - file: "rust-analyzer-aarch64-apple-darwin.gz", - bin: "rust-analyzer-aarch64-apple-darwin", - }, - GithubAsset { - target: Target::WinX64, - file: "rust-analyzer-x86_64-pc-windows-msvc.zip", - bin: "rust-analyzer.exe", - }, - GithubAsset { - target: Target::WinArm64, - file: "rust-analyzer-aarch64-pc-windows-msvc.zip", - bin: "rust-analyzer.exe", - }, - ], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "zls", - kinds: &[ToolKind::Lsp], - languages: &["zig"], - install: InstallSource::GithubRelease { - repo: "zigtools/zls", - assets: &[ - GithubAsset { - target: Target::LinuxX64Gnu, - file: "zls-x86_64-linux.tar.xz", - bin: "zls", - }, - GithubAsset { - target: Target::LinuxArm64Gnu, - file: "zls-aarch64-linux.tar.xz", - bin: "zls", - }, - GithubAsset { - target: Target::DarwinX64, - file: "zls-x86_64-macos.tar.xz", - bin: "zls", - }, - GithubAsset { - target: Target::DarwinArm64, - file: "zls-aarch64-macos.tar.xz", - bin: "zls", - }, - GithubAsset { - target: Target::WinX64, - file: "zls-x86_64-windows.zip", - bin: "zls.exe", - }, - GithubAsset { - target: Target::WinArm64, - file: "zls-aarch64-windows.zip", - bin: "zls.exe", - }, - ], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── Formatters (npm) ───────────────────────────────────────────────── - RegistryEntry { - id: "prettier", - kinds: &[ToolKind::Formatter], - languages: &[ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", - "vue", - "svelte", - "astro", - "html", - "css", - "scss", - "less", - "json", - "jsonc", - "yaml", - "markdown", - "mdx", - "graphql", - ], - install: InstallSource::Npm { - package: "prettier", - bin: "prettier", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "biome", - kinds: &[ToolKind::Formatter, ToolKind::Linter], - languages: &[ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", - "json", - "jsonc", - "css", - ], - install: InstallSource::Npm { - package: "@biomejs/biome", - bin: "biome", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── Formatters (pip) ───────────────────────────────────────────────── - RegistryEntry { - id: "black", - kinds: &[ToolKind::Formatter], - languages: &["python"], - install: InstallSource::Pip { - package: "black", - bin: "black", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "autopep8", - kinds: &[ToolKind::Formatter], - languages: &["python"], - install: InstallSource::Pip { - package: "autopep8", - bin: "autopep8", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "isort", - kinds: &[ToolKind::Formatter], - languages: &["python"], - install: InstallSource::Pip { - package: "isort", - bin: "isort", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "ruff", - kinds: &[ToolKind::Formatter, ToolKind::Linter], - languages: &["python"], - install: InstallSource::Pip { - package: "ruff", - bin: "ruff", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── Formatters (cargo) ─────────────────────────────────────────────── - RegistryEntry { - id: "stylua", - kinds: &[ToolKind::Formatter], - languages: &["lua"], - install: InstallSource::Cargo { - crate_name: "stylua", - bin: "stylua", - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── Linters (npm) ──────────────────────────────────────────────────── - RegistryEntry { - id: "eslint", - kinds: &[ToolKind::Linter], - languages: &[ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", - "vue", - "svelte", - "astro", - ], - install: InstallSource::Npm { - package: "eslint", - bin: "eslint", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "markdownlint", - kinds: &[ToolKind::Linter], - languages: &["markdown", "mdx"], - install: InstallSource::Npm { - package: "markdownlint-cli", - bin: "markdownlint", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "stylelint", - kinds: &[ToolKind::Linter], - languages: &["css", "scss", "less"], - install: InstallSource::Npm { - package: "stylelint", - bin: "stylelint", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - // ─── Linters (pip) ──────────────────────────────────────────────────── - RegistryEntry { - id: "pylint", - kinds: &[ToolKind::Linter], - languages: &["python"], - install: InstallSource::Pip { - package: "pylint", - bin: "pylint", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "mypy", - kinds: &[ToolKind::Linter], - languages: &["python"], - install: InstallSource::Pip { - package: "mypy", - bin: "mypy", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, - RegistryEntry { - id: "yamllint", - kinds: &[ToolKind::Linter], - languages: &["yaml"], - install: InstallSource::Pip { - package: "yamllint", - bin: "yamllint", - extra_packages: &[], - }, - launch: LaunchSpec { - args: &[], - env: &[], - }, - }, -]; diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs deleted file mode 100644 index c4bfcd23..00000000 --- a/crates/registry/src/lib.rs +++ /dev/null @@ -1,91 +0,0 @@ -mod entries; - -#[derive(Clone, Copy, Eq, PartialEq, Debug)] -pub enum ToolKind { - Lsp, - Formatter, - Linter, -} - -#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] -pub enum Target { - LinuxX64Gnu, - LinuxArm64Gnu, - LinuxX64Musl, - LinuxArm64Musl, - DarwinX64, - DarwinArm64, - WinX64, - WinArm64, -} - -#[derive(Clone, Debug)] -pub struct GithubAsset { - pub target: Target, - pub file: &'static str, - pub bin: &'static str, -} - -#[derive(Clone, Debug)] -pub enum InstallSource { - Npm { - package: &'static str, - bin: &'static str, - extra_packages: &'static [&'static str], - }, - Pip { - package: &'static str, - bin: &'static str, - extra_packages: &'static [&'static str], - }, - Cargo { - crate_name: &'static str, - bin: &'static str, - }, - Go { - module: &'static str, - bin: &'static str, - }, - GithubRelease { - repo: &'static str, - assets: &'static [GithubAsset], - }, -} - -#[derive(Clone, Debug)] -pub struct LaunchSpec { - pub args: &'static [&'static str], - pub env: &'static [(&'static str, &'static str)], -} - -#[derive(Clone, Debug)] -pub struct RegistryEntry { - pub id: &'static str, - pub kinds: &'static [ToolKind], - pub languages: &'static [&'static str], - pub install: InstallSource, - pub launch: LaunchSpec, -} - -use entries::ENTRIES; - -pub fn all() -> &'static [RegistryEntry] { - ENTRIES -} - -pub fn get(id: &str) -> Option<&'static RegistryEntry> { - ENTRIES.iter().find(|e| e.id == id) -} - -pub fn by_kind(kind: ToolKind) -> impl Iterator { - ENTRIES.iter().filter(move |e| e.kinds.contains(&kind)) -} - -pub fn for_language<'a>( - language: &'a str, - kind: ToolKind, -) -> impl Iterator + 'a { - ENTRIES - .iter() - .filter(move |e| e.kinds.contains(&kind) && e.languages.contains(&language)) -} diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml deleted file mode 100644 index 83109248..00000000 --- a/crates/settings/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "settings" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -icons = { path = "../icons" } -rusqlite = { workspace = true } -serde_json = "1" -storage = { path = "../storage" } -theme = { path = "../theme" } diff --git a/crates/settings/README.md b/crates/settings/README.md deleted file mode 100644 index 86cb6239..00000000 --- a/crates/settings/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# settings - -Role: settings model, registry, and storage. - -Owns: -- Setting values and typed value accessors. -- Built-in settings categories and controls. -- Settings database loading and saving. -- The GPUI global `Settings` state. - -Does Not Own: -- Settings tab rendering. -- Applying settings to windows or features. -- Tool installation logic. diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs deleted file mode 100644 index 6ece4b60..00000000 --- a/crates/settings/src/lib.rs +++ /dev/null @@ -1,55 +0,0 @@ -pub mod registry; -mod store; -mod value; - -use std::collections::HashMap; - -use gpui::{App, Global, SharedString}; - -pub use registry::{ALL, Category, DropdownOption, Setting, SettingControl}; -pub use value::SettingValue; - -pub struct Settings { - values: HashMap, -} - -impl Settings { - pub fn load() -> Self { - Self { - values: store::load_all(), - } - } - - pub fn categories() -> &'static [&'static Category] { - registry::ALL - } - - pub fn value(&self, setting: &Setting) -> SettingValue { - self.values - .get(setting.id) - .cloned() - .unwrap_or_else(|| setting.default_value()) - } - - pub fn get(&self, key: &str) -> Option<&SettingValue> { - self.values.get(key) - } - - pub fn set(&mut self, key: impl Into, value: SettingValue) { - let key: SharedString = key.into(); - store::save(key.as_ref(), &value); - self.values.insert(key, value); - } -} - -impl Global for Settings {} - -pub trait ActiveSettings { - fn settings(&self) -> &Settings; -} - -impl ActiveSettings for App { - fn settings(&self) -> &Settings { - self.global::() - } -} diff --git a/crates/settings/src/migrations/001_initial.sql b/crates/settings/src/migrations/001_initial.sql deleted file mode 100644 index efacc919..00000000 --- a/crates/settings/src/migrations/001_initial.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value_type TEXT NOT NULL CHECK (value_type IN ('bool', 'string', 'int')), - value TEXT NOT NULL -); diff --git a/crates/settings/src/migrations/002_allow_list_type.sql b/crates/settings/src/migrations/002_allow_list_type.sql deleted file mode 100644 index 4405843d..00000000 --- a/crates/settings/src/migrations/002_allow_list_type.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE settings_new ( - key TEXT PRIMARY KEY, - value_type TEXT NOT NULL CHECK (value_type IN ('bool', 'string', 'int', 'list')), - value TEXT NOT NULL -); - -INSERT INTO settings_new (key, value_type, value) -SELECT key, value_type, value FROM settings; - -DROP TABLE settings; - -ALTER TABLE settings_new RENAME TO settings; diff --git a/crates/settings/src/registry.rs b/crates/settings/src/registry.rs deleted file mode 100644 index 60946fed..00000000 --- a/crates/settings/src/registry.rs +++ /dev/null @@ -1,150 +0,0 @@ -use icons::IconName; -pub use theme::DropdownOption; - -use crate::value::SettingValue; - -pub struct Category { - pub id: &'static str, - pub name: &'static str, - pub icon: IconName, - pub settings: &'static [Setting], -} - -pub struct Setting { - pub id: &'static str, - pub name: &'static str, - pub description: Option<&'static str>, - pub group: Option<&'static str>, - pub control: SettingControl, -} - -impl Setting { - pub fn default_value(&self) -> SettingValue { - match &self.control { - SettingControl::Switch { default } => SettingValue::Bool(*default), - SettingControl::Input { default, .. } => SettingValue::String((*default).into()), - SettingControl::Number { default, .. } => SettingValue::Int(*default), - SettingControl::Dropdown { default, .. } => SettingValue::String((*default).into()), - SettingControl::MultiSelect { default, .. } => SettingValue::List( - default - .iter() - .map(|s| SettingValue::String((*s).into())) - .collect(), - ), - } - } -} - -pub enum SettingControl { - Switch { - default: bool, - }, - Input { - default: &'static str, - placeholder: Option<&'static str>, - }, - Number { - default: i64, - min: Option, - max: Option, - step: i64, - }, - Dropdown { - default: &'static str, - options: &'static [DropdownOption], - }, - MultiSelect { - default: &'static [&'static str], - options: fn() -> &'static [DropdownOption], - ordered: bool, - }, -} - -pub const EDITOR: Category = Category { - id: "editor", - name: "Editor", - icon: IconName::Edit, - settings: &[Setting { - id: "editor.soft_wrap", - name: "Soft Wrap", - description: Some( - "Wrap long lines to the editor's width instead of scrolling horizontally.", - ), - group: None, - control: SettingControl::Switch { default: false }, - }], -}; - -pub const APPEARANCE: Category = Category { - id: "appearance", - name: "Appearance", - icon: IconName::SettingsGear, - settings: &[ - Setting { - id: "appearance.theme", - name: "Theme", - description: Some("Color theme used across the interface."), - group: None, - control: SettingControl::Dropdown { - default: theme::DEFAULT_ID, - options: theme::REGISTRY, - }, - }, - Setting { - id: "appearance.zoom", - name: "Zoom", - description: Some("Interface zoom level, in percent."), - group: None, - control: SettingControl::Number { - default: 100, - min: Some(75), - max: Some(125), - step: 5, - }, - }, - ], -}; - -// Categories below have no row-based settings — their content is rendered -// by custom card UIs (dispatched by category id in the settings renderer). -// Install presence is read from the filesystem; formatters/linters are -// auto-picked per file from the registry, so no per-language config exists. - -pub const LANGUAGE_SERVERS: Category = Category { - id: "language_servers", - name: "Language Servers", - icon: IconName::Terminal, - settings: &[], -}; - -pub const FORMATTERS: Category = Category { - id: "formatters", - name: "Formatters", - icon: IconName::Edit, - settings: &[], -}; - -pub const LINTERS: Category = Category { - id: "linters", - name: "Linters", - icon: IconName::Clippy, - settings: &[], -}; - -pub const ALL: &[&Category] = &[ - &APPEARANCE, - &EDITOR, - &LANGUAGE_SERVERS, - &FORMATTERS, - &LINTERS, -]; - -pub fn category(id: &str) -> Option<&'static Category> { - ALL.iter().copied().find(|c| c.id == id) -} - -pub fn setting(id: &str) -> Option<&'static Setting> { - ALL.iter() - .flat_map(|c| c.settings.iter()) - .find(|s| s.id == id) -} diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs deleted file mode 100644 index f8f32f7a..00000000 --- a/crates/settings/src/store.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Mutex, OnceLock}; - -use gpui::SharedString; -use rusqlite::{Connection, Result, params}; -use storage::{Database, with_connection}; - -use crate::value::SettingValue; - -const MIGRATIONS: &[&str] = &[ - include_str!("migrations/001_initial.sql"), - include_str!("migrations/002_allow_list_type.sql"), -]; - -static CONN: OnceLock>> = OnceLock::new(); - -const DATABASE: Database = Database { - file_name: "settings.db", - label: "settings", - migrations: MIGRATIONS, - foreign_keys: false, -}; - -pub fn load_all() -> HashMap { - with(load_inner).unwrap_or_default() -} - -pub fn save(key: &str, value: &SettingValue) { - with(|conn| save_inner(conn, key, value)); -} - -fn load_inner(conn: &mut Connection) -> Result> { - let mut stmt = conn.prepare("SELECT key, value_type, value FROM settings")?; - let rows = stmt.query_map([], |row| { - let key: String = row.get(0)?; - let kind: String = row.get(1)?; - let raw: String = row.get(2)?; - Ok((key, kind, raw)) - })?; - let mut map = HashMap::new(); - for row in rows { - let (key, kind, raw) = row?; - let Some(value) = decode(&kind, &raw) else { - continue; - }; - map.insert(SharedString::from(key), value); - } - Ok(map) -} - -fn save_inner(conn: &mut Connection, key: &str, value: &SettingValue) -> Result<()> { - let (kind, raw) = encode(value); - conn.execute( - "INSERT INTO settings (key, value_type, value) VALUES (?1, ?2, ?3) - ON CONFLICT(key) DO UPDATE SET - value_type = excluded.value_type, - value = excluded.value", - params![key, kind, raw], - )?; - Ok(()) -} - -fn encode(value: &SettingValue) -> (&'static str, String) { - match value { - SettingValue::Bool(b) => ("bool", if *b { "1" } else { "0" }.to_string()), - SettingValue::String(s) => ("string", s.to_string()), - SettingValue::Int(i) => ("int", i.to_string()), - SettingValue::List(items) => { - let arr: Vec = items.iter().map(value_to_json).collect(); - ("list", serde_json::Value::Array(arr).to_string()) - } - } -} - -fn decode(kind: &str, raw: &str) -> Option { - match kind { - "bool" => Some(SettingValue::Bool(raw == "1" || raw == "true")), - "string" => Some(SettingValue::String(SharedString::from(raw.to_string()))), - "int" => raw.parse::().ok().map(SettingValue::Int), - "list" => { - let json_value: serde_json::Value = serde_json::from_str(raw).ok()?; - let json_items = json_value.as_array()?; - let items: Option> = json_items.iter().map(value_from_json).collect(); - Some(SettingValue::List(items?)) - } - _ => None, - } -} - -fn value_to_json(value: &SettingValue) -> serde_json::Value { - match value { - SettingValue::Bool(b) => serde_json::json!({ "type": "bool", "value": *b }), - SettingValue::String(s) => serde_json::json!({ "type": "string", "value": s.as_ref() }), - SettingValue::Int(i) => serde_json::json!({ "type": "int", "value": *i }), - SettingValue::List(items) => { - let arr: Vec = items.iter().map(value_to_json).collect(); - serde_json::json!({ "type": "list", "value": arr }) - } - } -} - -fn value_from_json(json_value: &serde_json::Value) -> Option { - let kind = json_value.get("type")?.as_str()?; - let inner = json_value.get("value")?; - match kind { - "bool" => Some(SettingValue::Bool(inner.as_bool()?)), - "string" => Some(SettingValue::String(SharedString::from( - inner.as_str()?.to_string(), - ))), - "int" => Some(SettingValue::Int(inner.as_i64()?)), - "list" => { - let arr = inner.as_array()?; - let items: Option> = arr.iter().map(value_from_json).collect(); - Some(SettingValue::List(items?)) - } - _ => None, - } -} - -fn with(f: F) -> Option -where - F: FnOnce(&mut Connection) -> Result, -{ - with_connection(&CONN, DATABASE, f) -} diff --git a/crates/settings/src/value.rs b/crates/settings/src/value.rs deleted file mode 100644 index 674d7a84..00000000 --- a/crates/settings/src/value.rs +++ /dev/null @@ -1,39 +0,0 @@ -use gpui::SharedString; - -#[derive(Clone, Debug, PartialEq)] -pub enum SettingValue { - Bool(bool), - String(SharedString), - Int(i64), - List(Vec), -} - -impl SettingValue { - pub fn as_bool(&self) -> Option { - match self { - Self::Bool(b) => Some(*b), - _ => None, - } - } - - pub fn as_str(&self) -> Option<&str> { - match self { - Self::String(s) => Some(s.as_ref()), - _ => None, - } - } - - pub fn as_int(&self) -> Option { - match self { - Self::Int(i) => Some(*i), - _ => None, - } - } - - pub fn as_list(&self) -> Option<&[SettingValue]> { - match self { - Self::List(v) => Some(v), - _ => None, - } - } -} diff --git a/crates/shortcuts/Cargo.toml b/crates/shortcuts/Cargo.toml deleted file mode 100644 index fa94c12a..00000000 --- a/crates/shortcuts/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "shortcuts" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } diff --git a/crates/shortcuts/README.md b/crates/shortcuts/README.md deleted file mode 100644 index 76aed489..00000000 --- a/crates/shortcuts/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# shortcuts - -Role: key binding registration. - -Owns: -- Default shortcut declarations. -- Mapping keystrokes to GPUI action names. -- Installing shortcuts into GPUI's keymap. - -Does Not Own: -- Action type definitions. -- Action handlers. -- User-facing keybinding UI. diff --git a/crates/shortcuts/src/lib.rs b/crates/shortcuts/src/lib.rs deleted file mode 100644 index be487e7f..00000000 --- a/crates/shortcuts/src/lib.rs +++ /dev/null @@ -1,239 +0,0 @@ -use gpui::{App, Global, KeyBinding, Keystroke}; - -/// Keymap context that activates the global Kosmos shortcuts. -pub const CONTEXT: &str = "Kosmos"; -const GLOBAL_SHORTCUT_CONTEXT: &str = "Kosmos && !Terminal"; - -/// A keystroke -> action mapping prior to resolution against the action registry. -/// Settings will eventually deserialize into this same shape so the install path is shared. -/// -/// Action types and their handlers live at the UI boundary that owns them; -/// this crate only stores the keymap that pairs keystrokes to action names and -/// drives gpui's binding registration. -#[derive(Clone, Debug)] -pub struct ShortcutBinding { - pub keystrokes: &'static str, - pub action: &'static str, - pub context: &'static str, -} - -#[derive(Clone, Debug)] -pub struct ShortcutRegistry { - bindings: Vec, -} - -impl ShortcutRegistry { - pub fn new(bindings: Vec) -> Self { - Self { bindings } - } - - pub fn primary_label_for_action(&self, action: &str) -> Option { - primary_label_for_action_in(&self.bindings, action) - } - - pub fn primary_keystroke_for_action(&self, action: &str) -> Option { - primary_keystroke_for_action_in(&self.bindings, action) - } -} - -impl Global for ShortcutRegistry {} - -impl ShortcutBinding { - pub const fn new(keystrokes: &'static str, action: &'static str) -> Self { - Self::in_context(keystrokes, action, GLOBAL_SHORTCUT_CONTEXT) - } - - pub const fn in_context( - keystrokes: &'static str, - action: &'static str, - context: &'static str, - ) -> Self { - Self { - keystrokes, - action, - context, - } - } -} - -pub const DEFAULTS: &[ShortcutBinding] = &[ - ShortcutBinding::new("ctrl-w", "pane_tree::CloseTab"), - ShortcutBinding::new("ctrl-t", "pane_tree::NewTab"), - ShortcutBinding::new("ctrl-s", "file_editor::Save"), - ShortcutBinding::new("ctrl-=", "zoom::ZoomIn"), - ShortcutBinding::new("ctrl-+", "zoom::ZoomIn"), - ShortcutBinding::new("ctrl--", "zoom::ZoomOut"), - ShortcutBinding::new("ctrl-0", "zoom::ResetZoom"), - ShortcutBinding::in_context("ctrl-a", "input::SelectAll", "Input"), - ShortcutBinding::in_context("ctrl-c", "input::Copy", "Input"), - ShortcutBinding::in_context("ctrl-v", "input::Paste", "Input"), - ShortcutBinding::in_context("ctrl-x", "input::Cut", "Input"), - ShortcutBinding::in_context("ctrl-z", "input::Undo", "Input"), - ShortcutBinding::in_context("ctrl-y", "input::Redo", "Input"), - ShortcutBinding::in_context("ctrl-shift-z", "input::Redo", "Input"), -]; - -/// Install a list of shortcut bindings into the app keymap. Bindings whose action -/// is not registered or whose keystrokes do not parse are silently skipped so a -/// single bad entry from settings cannot break the whole keymap. -pub fn install(cx: &mut App, bindings: &[ShortcutBinding]) { - let mapper = cx.keyboard_mapper().clone(); - let mut key_bindings = Vec::with_capacity(bindings.len()); - let mut installed_bindings = Vec::with_capacity(bindings.len()); - for binding in bindings { - let Ok(action) = cx.build_action(binding.action, None) else { - continue; - }; - let Ok(key_binding) = KeyBinding::load( - binding.keystrokes, - action, - Some(parse_context(binding.context)), - false, - None, - mapper.as_ref(), - ) else { - continue; - }; - installed_bindings.push(binding.clone()); - key_bindings.push(key_binding); - } - cx.bind_keys(key_bindings); - cx.set_global(ShortcutRegistry::new(installed_bindings)); -} - -/// Convenience for the common case of registering only the built-in defaults. -pub fn install_defaults(cx: &mut App) { - install(cx, DEFAULTS); -} - -fn parse_context(source: &str) -> std::rc::Rc { - gpui::KeyBindingContextPredicate::parse(source) - .expect("hard-coded keymap context must parse") - .into() -} - -pub fn primary_label_for_action(action: &str, cx: &App) -> Option { - if let Some(registry) = cx.try_global::() { - return registry.primary_label_for_action(action); - } - primary_label_for_action_in(DEFAULTS, action) -} - -pub fn primary_keystroke_for_action(action: &str, cx: &App) -> Option { - if let Some(registry) = cx.try_global::() { - return registry.primary_keystroke_for_action(action); - } - primary_keystroke_for_action_in(DEFAULTS, action) -} - -pub fn primary_label_for_action_in(bindings: &[ShortcutBinding], action: &str) -> Option { - bindings - .iter() - .find(|binding| binding.action == action) - .map(|binding| format_keystrokes(binding.keystrokes)) -} - -pub fn primary_keystroke_for_action_in( - bindings: &[ShortcutBinding], - action: &str, -) -> Option { - bindings - .iter() - .find(|binding| binding.action == action) - .and_then(|binding| binding.keystrokes.split_whitespace().next()) - .and_then(|keystrokes| Keystroke::parse(keystrokes).ok()) -} - -pub fn format_keystrokes(keystrokes: &str) -> String { - keystrokes - .split_whitespace() - .map(format_chord) - .collect::>() - .join(" ") -} - -fn format_chord(chord: &str) -> String { - let mut remaining = chord; - let mut parts = Vec::new(); - loop { - match parse_modifier(remaining) { - Some((label, rest)) => { - parts.push(label.to_string()); - remaining = rest; - } - None => { - parts.push(format_key(remaining)); - break; - } - } - } - parts.join("+") -} - -fn parse_modifier(source: &str) -> Option<(&'static str, &str)> { - [ - ("ctrl-", "Ctrl"), - ("cmd-", "Cmd"), - ("alt-", "Alt"), - ("shift-", "Shift"), - ] - .into_iter() - .find_map(|(prefix, label)| source.strip_prefix(prefix).map(|rest| (label, rest))) -} - -fn format_key(key: &str) -> String { - match key { - "esc" | "escape" => "Esc".to_string(), - "backspace" => "Backspace".to_string(), - "delete" => "Delete".to_string(), - "enter" => "Enter".to_string(), - "space" => "Space".to_string(), - "tab" => "Tab".to_string(), - "left" => "Left".to_string(), - "right" => "Right".to_string(), - "up" => "Up".to_string(), - "down" => "Down".to_string(), - key if key.len() == 1 => key.to_ascii_uppercase(), - key => key.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn formats_shortcut_labels_for_display() { - assert_eq!(format_keystrokes("ctrl-s"), "Ctrl+S"); - assert_eq!(format_keystrokes("ctrl-shift-z"), "Ctrl+Shift+Z"); - assert_eq!(format_keystrokes("alt-shift-up"), "Alt+Shift+Up"); - assert_eq!(format_keystrokes("ctrl--"), "Ctrl+-"); - } - - #[test] - fn finds_primary_shortcut_for_action() { - assert_eq!( - primary_label_for_action_in(DEFAULTS, "file_editor::Save"), - Some("Ctrl+S".to_string()) - ); - assert_eq!( - primary_label_for_action_in(DEFAULTS, "input::Redo"), - Some("Ctrl+Y".to_string()) - ); - } - - #[test] - fn finds_primary_keystroke_for_action() { - assert_eq!( - primary_keystroke_for_action_in(DEFAULTS, "file_editor::Save"), - Keystroke::parse("ctrl-s").ok() - ); - } - - #[test] - fn global_shortcuts_skip_terminal_context() { - let global_binding = ShortcutBinding::new("ctrl-t", "pane_tree::NewTab"); - assert_eq!(global_binding.context, "Kosmos && !Terminal"); - assert_eq!(CONTEXT, "Kosmos"); - } -} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml deleted file mode 100644 index 8e3c6fe7..00000000 --- a/crates/storage/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "storage" -version = "0.4.9" -edition = "2024" - -[dependencies] -rusqlite = { workspace = true } diff --git a/crates/storage/README.md b/crates/storage/README.md deleted file mode 100644 index 42ba05b6..00000000 --- a/crates/storage/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# storage - -Role: shared SQLite database plumbing. - -Owns: -- Kosmos data-directory database path resolution. -- SQLite connection initialization. -- Common pragmas and migration execution. -- Thread-safe global connection slots used by higher-level storage crates. - -Does Not Own: -- Table schemas. -- Domain-specific load/save functions. -- Settings or session data models. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs deleted file mode 100644 index 2c58dd8f..00000000 --- a/crates/storage/src/lib.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; - -use rusqlite::{Connection, Result}; - -#[derive(Clone, Copy)] -pub struct Database { - pub file_name: &'static str, - pub label: &'static str, - pub migrations: &'static [&'static str], - pub foreign_keys: bool, -} - -pub fn with_connection( - slot: &'static OnceLock>>, - database: Database, - f: F, -) -> Option -where - F: FnOnce(&mut Connection) -> Result, -{ - let connection_mutex = slot.get_or_init(|| open(database)).as_ref()?; - let mut connection = connection_mutex.lock().unwrap(); - match f(&mut connection) { - Ok(value) => Some(value), - Err(err) => { - eprintln!("kosmos: {} db error: {err}", database.label); - None - } - } -} - -fn open(database: Database) -> Option> { - let path = data_path(database.file_name)?; - if let Some(parent) = path.parent() - && let Err(err) = fs::create_dir_all(parent) - { - eprintln!("kosmos: failed to create {}: {err}", parent.display()); - return None; - } - let mut conn = match Connection::open(&path) { - Ok(c) => c, - Err(err) => { - eprintln!( - "kosmos: failed to open {} db at {}: {err}", - database.label, - path.display() - ); - return None; - } - }; - if let Err(err) = init(&mut conn, database) { - eprintln!("kosmos: {} db init failed: {err}", database.label); - return None; - } - Some(Mutex::new(conn)) -} - -fn init(conn: &mut Connection, database: Database) -> Result<()> { - conn.pragma_update(None, "journal_mode", "WAL")?; - conn.pragma_update(None, "synchronous", "NORMAL")?; - if database.foreign_keys { - conn.pragma_update(None, "foreign_keys", "ON")?; - } - let current: u32 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; - for (i, sql) in database.migrations.iter().enumerate() { - let version = (i + 1) as u32; - if version <= current { - continue; - } - let tx = conn.transaction()?; - tx.execute_batch(sql)?; - tx.pragma_update(None, "user_version", version)?; - tx.commit()?; - } - Ok(()) -} - -fn data_path(file_name: &str) -> Option { - let home = std::env::var_os("HOME")?; - Some(PathBuf::from(home).join(".kosmos").join(file_name)) -} diff --git a/crates/tabs/Cargo.toml b/crates/tabs/Cargo.toml deleted file mode 100644 index fbc87bf1..00000000 --- a/crates/tabs/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "tabs" -version = "0.4.9" -edition = "2024" - -[dependencies] diff --git a/crates/tabs/README.md b/crates/tabs/README.md deleted file mode 100644 index be7f7124..00000000 --- a/crates/tabs/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# tabs - -Role: pure tab metadata and tab kind registry. - -Owns: -- `Tab` identity, kind, optional title, and optional path. -- `TabKind` metadata for built-in tab types. -- Lookup of tab kinds by id. - -Does Not Own: -- Icons. -- Rendering tab headers or tab bodies. -- Pane layout. -- Feature-specific tab behavior. - -This crate should remain dependency-light because it sits under pane and persistence models. diff --git a/crates/tabs/src/lib.rs b/crates/tabs/src/lib.rs deleted file mode 100644 index de2aa066..00000000 --- a/crates/tabs/src/lib.rs +++ /dev/null @@ -1,48 +0,0 @@ -pub mod registry; - -use std::path::PathBuf; - -pub use registry::TabKind; - -#[derive(Clone)] -pub struct Tab { - pub id: usize, - pub kind: String, - pub title: Option, - pub path: Option, -} - -impl Tab { - pub fn new(id: usize, kind: &'static TabKind) -> Self { - Self { - id, - kind: kind.id.to_string(), - title: None, - path: None, - } - } - - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_path(mut self, path: PathBuf) -> Self { - self.path = Some(path); - self - } - - pub fn kind(&self) -> Option<&'static TabKind> { - registry::get(&self.kind) - } - - pub fn title(&self) -> String { - if let Some(title) = &self.title { - return title.clone(); - } - match self.kind() { - Some(kind) => kind.name.to_string(), - None => self.kind.clone(), - } - } -} diff --git a/crates/tabs/src/registry.rs b/crates/tabs/src/registry.rs deleted file mode 100644 index db868b6c..00000000 --- a/crates/tabs/src/registry.rs +++ /dev/null @@ -1,68 +0,0 @@ -pub struct TabKind { - pub id: &'static str, - pub name: &'static str, - pub is_hidden: bool, -} - -pub const BLANK: TabKind = TabKind { - id: "blank", - name: "Blank", - is_hidden: true, -}; - -pub const FILE_TREE: TabKind = TabKind { - id: "file_tree", - name: "File Tree", - is_hidden: false, -}; - -pub const FILE_SEARCH: TabKind = TabKind { - id: "file_search", - name: "Search", - is_hidden: false, -}; - -pub const GIT: TabKind = TabKind { - id: "git", - name: "Git", - is_hidden: false, -}; - -pub const DIFF: TabKind = TabKind { - id: "diff", - name: "Diff", - is_hidden: true, -}; - -pub const TERMINAL: TabKind = TabKind { - id: "terminal", - name: "Terminal", - is_hidden: false, -}; - -pub const SETTINGS: TabKind = TabKind { - id: "settings", - name: "Settings", - is_hidden: false, -}; - -pub const FILE_EDITOR: TabKind = TabKind { - id: "file_editor", - name: "Editor", - is_hidden: true, -}; - -pub const ALL: &[&TabKind] = &[ - &BLANK, - &FILE_TREE, - &FILE_SEARCH, - &GIT, - &DIFF, - &TERMINAL, - &SETTINGS, - &FILE_EDITOR, -]; - -pub fn get(id: &str) -> Option<&'static TabKind> { - ALL.iter().copied().find(|kind| kind.id == id) -} diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml deleted file mode 100644 index cbb23d45..00000000 --- a/crates/terminal/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "terminal" -version = "0.4.9" -edition = "2024" - -[dependencies] -alacritty_terminal = "0.26.0" -gpui = { workspace = true } -portable-pty = "0.9.0" diff --git a/crates/terminal/README.md b/crates/terminal/README.md deleted file mode 100644 index 4805dc7f..00000000 --- a/crates/terminal/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# terminal - -Role: terminal feature domain. - -Owns: -- Future terminal process/session models. -- Future pseudo-terminal lifecycle management. -- Future terminal output/input state independent of UI rendering. - -Does Not Own: -- Terminal tab UI. -- Pane layout. -- Application orchestration. - -Status: placeholder crate; current code is a scaffold only. diff --git a/crates/terminal/src/lib.rs b/crates/terminal/src/lib.rs deleted file mode 100644 index af5c66e6..00000000 --- a/crates/terminal/src/lib.rs +++ /dev/null @@ -1,3182 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::io::{Read, Write}; -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::process::{Command as ProcessCommand, Stdio}; -use std::sync::{ - Arc, - mpsc::{Receiver, Sender, channel}, -}; -use std::time::{Duration, Instant}; - -use alacritty_terminal::event::{Event, EventListener, WindowSize}; -use alacritty_terminal::grid::{Dimensions, Scroll}; -use alacritty_terminal::index::{Column, Line}; -use alacritty_terminal::term::cell::{Cell, Flags}; -use alacritty_terminal::term::{Config, Term, TermMode}; -use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Processor, Rgb}; -use gpui::{ - App, AppContext, BorrowAppContext, Context, Entity, EntityInputHandler, FocusHandle, Focusable, - Global, Task, UTF16Selection, Window, -}; -use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; - -const DEFAULT_COLUMNS: usize = 80; -const DEFAULT_ROWS: usize = 24; -const DEFAULT_CELL_WIDTH_PX: u16 = 8; -const DEFAULT_CELL_HEIGHT_PX: u16 = 18; -const OUTPUT_POLL_INTERVAL: Duration = Duration::from_millis(16); -const CWD_REFRESH_INTERVAL: Duration = Duration::from_millis(500); -const BELL_COOLDOWN: Duration = Duration::from_millis(80); -const READ_BUFFER_SIZE: usize = 8192; -const MIN_ZOOM_PERCENT: i64 = 50; -const MAX_ZOOM_PERCENT: i64 = 200; -const ZOOM_STEP_PERCENT: i64 = 5; - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub struct TerminalKey { - pub workspace_id: usize, - pub tab_id: usize, -} - -impl TerminalKey { - pub fn new(workspace_id: usize, tab_id: usize) -> Self { - Self { - workspace_id, - tab_id, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TerminalPalette { - Dark, - Light, -} - -impl TerminalPalette { - pub fn for_dark_theme(is_dark: bool) -> Self { - match is_dark { - true => Self::Dark, - false => Self::Light, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TerminalMouseButton { - Left, - Middle, - Right, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct TerminalMouseModifiers { - pub shift: bool, - pub alt: bool, - pub control: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TerminalSelectionMode { - Cell, - Word, - Line, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ShellProfile { - pub id: String, - pub label: String, - pub path: PathBuf, -} - -impl ShellProfile { - fn new(path: PathBuf) -> Option { - if !path.is_absolute() || !is_executable_file(&path) { - return None; - } - let label = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("shell") - .to_string(); - let id = path.to_string_lossy().into_owned(); - Some(Self { id, label, path }) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TerminalColor { - pub r: u8, - pub g: u8, - pub b: u8, -} - -impl TerminalColor { - pub const fn rgb(value: u32) -> Self { - Self { - r: ((value >> 16) & 0xff) as u8, - g: ((value >> 8) & 0xff) as u8, - b: (value & 0xff) as u8, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TerminalTheme { - pub palette: TerminalPalette, - pub foreground: TerminalColor, - pub background: TerminalColor, -} - -impl TerminalTheme { - pub fn new( - palette: TerminalPalette, - foreground: TerminalColor, - background: TerminalColor, - ) -> Self { - Self { - palette, - foreground, - background, - } - } - - pub fn for_palette(palette: TerminalPalette) -> Self { - match palette { - TerminalPalette::Dark => Self::new( - palette, - rgb_to_terminal(DARK_FALLBACK_FOREGROUND), - rgb_to_terminal(DARK_FALLBACK_BACKGROUND), - ), - TerminalPalette::Light => Self::new( - palette, - rgb_to_terminal(LIGHT_FALLBACK_FOREGROUND), - rgb_to_terminal(LIGHT_FALLBACK_BACKGROUND), - ), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TerminalStyle { - pub foreground: TerminalColor, - pub background: TerminalColor, - pub bold: bool, - pub italic: bool, - pub underline: bool, - pub dim: bool, - pub strikeout: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalTextRun { - pub range: Range, - pub style: TerminalStyle, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalCellRun { - pub text: String, - pub style: TerminalStyle, - pub column: usize, - pub width: usize, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalCell { - pub text: String, - pub style: TerminalStyle, - pub column: usize, - pub width: usize, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalRow { - pub text: String, - pub runs: Vec, - pub cell_runs: Vec, - pub cells: Vec, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TerminalSelectionRange { - pub row: usize, - pub column: usize, - pub width: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TerminalCursorShape { - Block, - Underline, - Beam, - HollowBlock, - Hidden, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TerminalCursor { - pub row: usize, - pub column: usize, - pub shape: TerminalCursorShape, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalSnapshot { - pub rows: Vec, - pub cursor: Option, - pub cursor_color: TerminalColor, - pub selection_ranges: Vec, - pub columns: usize, - pub screen_rows: usize, - pub display_offset: usize, - pub title: Option, - pub selected_shell_id: String, - pub selected_shell_label: String, - pub shells: Vec, - pub zoom_percent: i64, - pub status: TerminalStatus, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TerminalStatus { - Running, - Restarting, - Failed(String), - Exited, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct TerminalKeyInput { - pub key: String, - pub text: Option, - pub control: bool, - pub alt: bool, - pub shift: bool, - pub platform: bool, -} - -#[derive(Default)] -pub struct TerminalStore { - sessions: HashMap>, -} - -impl TerminalStore { - pub fn install(cx: &mut App) { - cx.set_global(Self::default()); - } - - pub fn for_tab(key: TerminalKey, cwd: PathBuf, cx: &mut App) -> Entity { - if let Some(existing) = cx - .try_global::() - .and_then(|store| store.sessions.get(&key).cloned()) - { - return existing; - } - - let entity = cx.new(|cx| TerminalSession::new(key, cwd.clone(), cx)); - cx.update_global::(|store, _| { - store.sessions.insert(key, entity.clone()); - }); - entity - } - - pub fn get(key: TerminalKey, cx: &App) -> Option> { - cx.try_global::() - .and_then(|store| store.sessions.get(&key).cloned()) - } - - pub fn cwd(key: TerminalKey, cx: &mut App) -> Option { - let session = cx - .try_global::() - .and_then(|store| store.sessions.get(&key).cloned())?; - Some(session.update(cx, |session, _| { - session.refresh_current_directory(); - session.cwd.clone() - })) - } - - pub fn drop_tab(key: TerminalKey, cx: &mut App) { - let Some(session) = cx - .try_global::() - .and_then(|store| store.sessions.get(&key).cloned()) - else { - return; - }; - session.update(cx, |session, _| session.shutdown()); - cx.update_global::(|store, _| { - store.sessions.remove(&key); - }); - } - - pub fn drop_workspace(workspace_id: usize, cx: &mut App) { - let keys = cx - .try_global::() - .map(|store| { - store - .sessions - .keys() - .copied() - .filter(|key| key.workspace_id == workspace_id) - .collect::>() - }) - .unwrap_or_default(); - for key in keys { - Self::drop_tab(key, cx); - } - } -} - -impl Global for TerminalStore {} - -pub struct TerminalSession { - key: TerminalKey, - cwd: PathBuf, - shells: Vec, - selected_shell_id: String, - parser: Processor, - term: Term, - size: TerminalSize, - process: Option, - output_rx: Option>>, - event_rx: Receiver, - event_tx: Sender, - focus_handle: FocusHandle, - poll_task: Option>, - observed_by_ui: bool, - theme: TerminalTheme, - selection: Option, - selecting: bool, - selection_mode: TerminalSelectionMode, - selection_anchor: Option, - mouse_pressed_button: Option, - zoom_percent: i64, - status: TerminalStatus, - title: Option, - last_cwd_refresh: Instant, - snapshot_cache: Option>, - snapshot_dirty: bool, - last_bell: Option, - ime_marked_text: Option, -} - -impl TerminalSession { - pub fn new(key: TerminalKey, cwd: PathBuf, cx: &mut Context) -> Self { - let shells = discover_shells(); - let selected_shell_id = shells - .first() - .map(|shell| shell.id.clone()) - .unwrap_or_else(|| fallback_shell_profile().id); - let (event_tx, event_rx) = channel(); - let size = TerminalSize::default(); - let term = new_term(size, event_tx.clone()); - let mut session = Self { - key, - cwd, - shells, - selected_shell_id, - parser: Processor::new(), - term, - size, - process: None, - output_rx: None, - event_rx, - event_tx, - focus_handle: cx.focus_handle(), - poll_task: None, - observed_by_ui: false, - theme: TerminalTheme::for_palette(TerminalPalette::Dark), - selection: None, - selecting: false, - selection_mode: TerminalSelectionMode::Cell, - selection_anchor: None, - mouse_pressed_button: None, - zoom_percent: 100, - status: TerminalStatus::Restarting, - title: None, - last_cwd_refresh: Instant::now(), - snapshot_cache: None, - snapshot_dirty: true, - last_bell: None, - ime_marked_text: None, - }; - session.start_process(); - session.start_poll_task(cx); - session - } - - pub fn key(&self) -> TerminalKey { - self.key - } - - pub fn focus_handle(&self) -> FocusHandle { - self.focus_handle.clone() - } - - pub fn zoom_percent(&self) -> i64 { - self.zoom_percent - } - - pub fn observed_by_ui(&self) -> bool { - self.observed_by_ui - } - - pub fn mark_observed_by_ui(&mut self) { - self.observed_by_ui = true; - } - - pub fn set_theme(&mut self, theme: TerminalTheme) { - if self.theme == theme { - return; - } - self.theme = theme; - self.invalidate_snapshot(); - } - - pub fn snapshot(&mut self) -> Arc { - if !self.snapshot_dirty - && let Some(snapshot) = &self.snapshot_cache - { - return snapshot.clone(); - } - - let snapshot = Arc::new(self.build_snapshot()); - self.snapshot_cache = Some(snapshot.clone()); - self.snapshot_dirty = false; - snapshot - } - - fn build_snapshot(&self) -> TerminalSnapshot { - let content = self.term.renderable_content(); - let display_offset = content.display_offset; - let cursor_color = snapshot_cursor_color(content.colors, self.theme); - let cursor = snapshot_cursor(content.cursor, display_offset, self.size); - let selection_ranges = selection_ranges(self.selection, self.size, display_offset); - let rows = snapshot_rows(content, self.size, self.theme); - let selected_shell = self.selected_shell(); - TerminalSnapshot { - rows, - cursor, - cursor_color, - selection_ranges, - columns: self.size.columns, - screen_rows: self.size.rows, - display_offset, - title: self.title.clone(), - selected_shell_id: selected_shell.id.clone(), - selected_shell_label: selected_shell.label.clone(), - shells: self.shells.clone(), - zoom_percent: self.zoom_percent, - status: self.status.clone(), - } - } - - pub fn resize(&mut self, columns: usize, rows: usize, cell_width: u16, cell_height: u16) { - let next = TerminalSize { - columns: columns.max(alacritty_terminal::term::MIN_COLUMNS), - rows: rows.max(alacritty_terminal::term::MIN_SCREEN_LINES), - cell_width, - cell_height, - }; - if self.size == next { - return; - } - self.size = next; - self.term.resize(next); - self.invalidate_snapshot(); - if let Some(process) = &mut self.process { - process.resize(next); - } - } - - pub fn write_text(&mut self, text: &str) { - if text.is_empty() { - return; - } - self.write_to_pty(text.as_bytes()); - } - - pub fn write_key(&mut self, input: TerminalKeyInput) -> bool { - if let Some(bytes) = encode_key_input(&input, *self.term.mode()) { - self.write_to_pty(bytes.as_bytes()); - return true; - } - false - } - - pub fn paste(&mut self, text: &str) { - if text.is_empty() { - return; - } - if self.term.mode().contains(TermMode::BRACKETED_PASTE) { - self.write_text("\x1b[200~"); - self.write_text(text); - self.write_text("\x1b[201~"); - } else { - self.write_text(text); - } - } - - pub fn clear(&mut self, cx: &mut Context) { - self.feed_bytes(b"\x1b[2J\x1b[3J\x1b[H"); - cx.notify(); - } - - pub fn reload(&mut self, cx: &mut Context) { - self.restart(cx); - } - - pub fn select_shell(&mut self, shell_id: &str, cx: &mut Context) { - if self.selected_shell_id == shell_id { - return; - } - if !self.shells.iter().any(|shell| shell.id == shell_id) { - return; - } - self.selected_shell_id = shell_id.to_string(); - self.restart(cx); - } - - pub fn zoom_in(&mut self, cx: &mut Context) { - self.set_zoom(self.zoom_percent + ZOOM_STEP_PERCENT, cx); - } - - pub fn zoom_out(&mut self, cx: &mut Context) { - self.set_zoom(self.zoom_percent - ZOOM_STEP_PERCENT, cx); - } - - pub fn reset_zoom(&mut self, cx: &mut Context) { - self.set_zoom(100, cx); - } - - pub fn scroll_lines(&mut self, lines: i32, cx: &mut Context) { - if lines == 0 { - return; - } - if self - .term - .mode() - .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) - { - let key = if lines > 0 { "up" } else { "down" }; - for _ in 0..lines.unsigned_abs().min(128) { - self.write_key(TerminalKeyInput { - key: key.to_string(), - ..Default::default() - }); - } - return; - } - self.term.scroll_display(Scroll::Delta(lines)); - self.invalidate_snapshot(); - cx.notify(); - } - - pub fn mouse_down( - &mut self, - row: usize, - column: usize, - button: TerminalMouseButton, - selection_mode: TerminalSelectionMode, - modifiers: TerminalMouseModifiers, - cx: &mut Context, - ) -> bool { - let viewport_position = TerminalViewportPoint::new(row, column, self.size); - let position = self.selection_point_for_viewport(row, column); - if self.should_report_mouse(modifiers, button) { - self.selecting = false; - self.selection_mode = TerminalSelectionMode::Cell; - self.selection_anchor = None; - self.mouse_pressed_button = Some(button); - self.write_mouse_event( - TerminalMouseEventKind::Press(button), - viewport_position, - modifiers, - ); - return true; - } - - if button != TerminalMouseButton::Left { - return false; - } - - self.selection_mode = selection_mode; - self.selection_anchor = None; - match selection_mode { - TerminalSelectionMode::Cell => { - self.selection = Some(TerminalSelection::new(position)); - self.selecting = true; - } - TerminalSelectionMode::Word => { - self.selection = self.word_selection_at(position); - self.selection_anchor = self.selection; - self.selecting = self.selection.is_some(); - } - TerminalSelectionMode::Line => { - self.selection = Some(line_selection_at(position, self.size)); - self.selection_anchor = self.selection; - self.selecting = true; - } - } - self.invalidate_snapshot(); - cx.notify(); - true - } - - pub fn mouse_move( - &mut self, - row: usize, - column: usize, - modifiers: TerminalMouseModifiers, - cx: &mut Context, - ) -> bool { - let viewport_position = TerminalViewportPoint::new(row, column, self.size); - let position = self.selection_point_for_viewport(row, column); - if self.mouse_reporting_enabled() - && self.mouse_move_reporting_enabled() - && let Some(button) = self.mouse_pressed_button - { - self.write_mouse_event( - TerminalMouseEventKind::Move(button), - viewport_position, - modifiers, - ); - return true; - } - - if !self.selecting { - return false; - } - if self.update_selection_for_drag(position) { - self.invalidate_snapshot(); - cx.notify(); - } - true - } - - pub fn mouse_up( - &mut self, - row: usize, - column: usize, - button: TerminalMouseButton, - modifiers: TerminalMouseModifiers, - cx: &mut Context, - ) -> bool { - let viewport_position = TerminalViewportPoint::new(row, column, self.size); - let position = self.selection_point_for_viewport(row, column); - if self.mouse_reporting_enabled() && self.mouse_pressed_button.take().is_some() { - self.write_mouse_event( - TerminalMouseEventKind::Release(button), - viewport_position, - modifiers, - ); - return true; - } - - if !self.selecting { - return false; - } - self.selecting = false; - self.update_selection_for_drag(position); - self.selection_anchor = None; - if self.selection.is_some_and(|selection| selection.is_empty()) { - self.selection = None; - } - self.invalidate_snapshot(); - cx.notify(); - true - } - - pub fn scroll_lines_at( - &mut self, - row: usize, - column: usize, - lines: i32, - modifiers: TerminalMouseModifiers, - cx: &mut Context, - ) -> bool { - if lines == 0 { - return false; - } - if self.mouse_reporting_enabled() && !modifiers.shift { - let position = TerminalViewportPoint::new(row, column, self.size); - let direction = if lines > 0 { - TerminalMouseWheelDirection::Up - } else { - TerminalMouseWheelDirection::Down - }; - for _ in 0..lines.unsigned_abs().min(128) { - self.write_mouse_event( - TerminalMouseEventKind::Wheel(direction), - position, - modifiers, - ); - } - return true; - } - - self.scroll_lines(lines, cx); - true - } - - pub fn selected_text(&mut self) -> Option { - let text = self.text_for_selection(self.selection?, true)?; - (!text.is_empty()).then_some(text) - } - - pub fn shutdown(&mut self) { - self.process = None; - self.output_rx = None; - self.poll_task = None; - self.status = TerminalStatus::Exited; - self.invalidate_snapshot(); - } - - fn set_zoom(&mut self, percent: i64, cx: &mut Context) { - let clamped = percent.clamp(MIN_ZOOM_PERCENT, MAX_ZOOM_PERCENT); - if self.zoom_percent == clamped { - return; - } - self.zoom_percent = clamped; - self.invalidate_snapshot(); - cx.notify(); - } - - fn restart(&mut self, cx: &mut Context) { - self.refresh_current_directory(); - self.status = TerminalStatus::Restarting; - self.process = None; - self.output_rx = None; - self.poll_task = None; - self.parser = Processor::new(); - self.term = new_term(self.size, self.event_tx.clone()); - self.last_cwd_refresh = Instant::now(); - self.start_process(); - self.start_poll_task(cx); - self.invalidate_snapshot(); - self.last_bell = None; - cx.notify(); - } - - fn start_process(&mut self) { - let Some(shell) = self - .shells - .iter() - .find(|shell| shell.id == self.selected_shell_id) - .cloned() - .or_else(|| self.shells.first().cloned()) - else { - self.status = TerminalStatus::Failed("No shell found".to_string()); - return; - }; - - self.selected_shell_id = shell.id.clone(); - let pty_system = native_pty_system(); - let Ok(pair) = pty_system.openpty(pty_size(self.size)) else { - self.status = TerminalStatus::Failed("Failed to open PTY".to_string()); - return; - }; - - let reader = match pair.master.try_clone_reader() { - Ok(reader) => reader, - Err(err) => { - self.status = TerminalStatus::Failed(format!("Failed to read PTY: {err}")); - return; - } - }; - let writer = match pair.master.take_writer() { - Ok(writer) => writer, - Err(err) => { - self.status = TerminalStatus::Failed(format!("Failed to write PTY: {err}")); - return; - } - }; - - let mut command = CommandBuilder::new(&shell.path); - command.cwd(self.cwd.as_os_str()); - command.env("TERM", "xterm-256color"); - command.env("COLORTERM", "truecolor"); - command.env("TERM_PROGRAM", "kosmos"); - - let child = match pair.slave.spawn_command(command) { - Ok(child) => child, - Err(err) => { - self.status = TerminalStatus::Failed(format!("Failed to spawn shell: {err}")); - return; - } - }; - drop(pair.slave); - - let (output_tx, output_rx) = channel(); - spawn_reader(reader, output_tx); - self.output_rx = Some(output_rx); - self.process = Some(TerminalProcess::new(pair.master, writer, child)); - self.status = TerminalStatus::Running; - } - - fn start_poll_task(&mut self, cx: &mut Context) { - if self.poll_task.is_some() { - return; - } - let task = cx.spawn(async move |this, cx| { - loop { - cx.background_executor().timer(OUTPUT_POLL_INTERVAL).await; - let Ok(keep_running) = this.update(cx, |session, cx| session.poll(cx)) else { - break; - }; - if !keep_running { - let _ = this.update(cx, |session, _| { - session.poll_task = None; - }); - break; - } - } - }); - self.poll_task = Some(task); - } - - fn poll(&mut self, cx: &mut Context) -> bool { - let mut changed = false; - let mut disconnected = false; - let mut output: Option> = None; - - if let Some(rx) = &self.output_rx { - loop { - match rx.try_recv() { - Ok(chunk) if chunk.is_empty() => { - disconnected = true; - break; - } - Ok(chunk) => match output.as_mut() { - Some(bytes) => bytes.extend_from_slice(&chunk), - None => output = Some(chunk), - }, - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - } - if let Some(output) = output { - self.feed_bytes(&output); - changed = true; - } - if self.refresh_current_directory_if_due() { - changed = true; - } - if disconnected { - self.process = None; - self.output_rx = None; - if matches!( - self.status, - TerminalStatus::Running | TerminalStatus::Restarting - ) { - self.status = TerminalStatus::Exited; - self.invalidate_snapshot(); - changed = true; - } - } - if self.process.as_mut().is_some_and(TerminalProcess::try_wait) { - self.process = None; - if !matches!(self.status, TerminalStatus::Exited) { - self.status = TerminalStatus::Exited; - self.invalidate_snapshot(); - changed = true; - } - } - if self.drain_events() { - changed = true; - } - if changed { - cx.notify(); - } - self.process.is_some() || self.output_rx.is_some() - } - - fn feed_bytes(&mut self, bytes: &[u8]) { - if bytes.is_empty() { - return; - } - let mode_before = *self.term.mode(); - let had_selection = self.selection.is_some(); - let selected_before = self - .selection - .and_then(|selection| self.text_for_selection(selection, false)); - self.parser.advance(&mut self.term, bytes); - if had_selection { - let selected_after = self - .selection - .and_then(|selection| self.text_for_selection(selection, false)); - if should_clear_selection_after_output( - mode_before, - *self.term.mode(), - selected_before.as_deref(), - selected_after.as_deref(), - ) { - self.clear_selection(); - } - } - self.invalidate_snapshot(); - } - - fn invalidate_snapshot(&mut self) { - self.snapshot_dirty = true; - } - - fn refresh_current_directory(&mut self) -> bool { - let Some(cwd) = self - .process - .as_ref() - .and_then(TerminalProcess::current_directory) - else { - return false; - }; - if self.cwd == cwd { - return false; - } - self.cwd = cwd; - true - } - - fn refresh_current_directory_if_due(&mut self) -> bool { - if self.last_cwd_refresh.elapsed() < CWD_REFRESH_INTERVAL { - return false; - } - self.last_cwd_refresh = Instant::now(); - self.refresh_current_directory() - } - - fn drain_events(&mut self) -> bool { - let mut changed = false; - while let Ok(event) = self.event_rx.try_recv() { - match event { - Event::PtyWrite(text) => self.write_text(&text), - Event::Title(title) => { - self.title = Some(title); - self.invalidate_snapshot(); - changed = true; - } - Event::ResetTitle => { - self.title = None; - self.invalidate_snapshot(); - changed = true; - } - Event::ColorRequest(index, formatter) => { - let rgb = rgb_for_index(index, self.term.colors(), self.theme) - .unwrap_or_else(|| default_foreground(self.theme)); - self.write_text(&formatter(rgb)); - } - Event::TextAreaSizeRequest(formatter) => { - self.write_text(&formatter(WindowSize { - num_lines: self.size.rows as u16, - num_cols: self.size.columns as u16, - cell_width: self.size.cell_width, - cell_height: self.size.cell_height, - })); - } - Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {} - Event::ChildExit(_) => { - self.status = TerminalStatus::Exited; - self.invalidate_snapshot(); - changed = true; - } - Event::Exit => { - self.status = TerminalStatus::Exited; - self.invalidate_snapshot(); - changed = true; - } - Event::Bell => self.ring_bell(), - Event::MouseCursorDirty | Event::CursorBlinkingChange | Event::Wakeup => { - self.invalidate_snapshot(); - changed = true; - } - } - } - changed - } - - fn ring_bell(&mut self) { - let now = Instant::now(); - if self - .last_bell - .is_some_and(|last_bell| now.duration_since(last_bell) < BELL_COOLDOWN) - { - return; - } - self.last_bell = Some(now); - play_terminal_bell(); - } - - fn write_to_pty(&mut self, bytes: &[u8]) { - if let Some(process) = &mut self.process { - process.write(bytes); - } - } - - fn selected_shell(&self) -> ShellProfile { - self.shells - .iter() - .find(|shell| shell.id == self.selected_shell_id) - .cloned() - .or_else(|| self.shells.first().cloned()) - .unwrap_or_else(fallback_shell_profile) - } - - fn mouse_reporting_enabled(&self) -> bool { - self.term.mode().intersects(TermMode::MOUSE_MODE) - } - - fn mouse_move_reporting_enabled(&self) -> bool { - self.term - .mode() - .intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) - } - - fn should_report_mouse( - &self, - modifiers: TerminalMouseModifiers, - button: TerminalMouseButton, - ) -> bool { - self.mouse_reporting_enabled() && !(modifiers.shift && button == TerminalMouseButton::Left) - } - - fn write_mouse_event( - &mut self, - kind: TerminalMouseEventKind, - position: TerminalViewportPoint, - modifiers: TerminalMouseModifiers, - ) { - if let Some(bytes) = encode_mouse_event(kind, position, modifiers, *self.term.mode()) { - self.write_to_pty(&bytes); - } - } - - fn text_for_selection( - &self, - selection: TerminalSelection, - trim_trailing_spaces: bool, - ) -> Option { - text_for_selection( - &self.term, - self.size, - self.theme, - selection, - trim_trailing_spaces, - ) - } - - fn selection_point_for_viewport(&self, row: usize, column: usize) -> TerminalSelectionPoint { - TerminalSelectionPoint::from_viewport( - row, - column, - self.term.grid().display_offset(), - self.size, - ) - } - - fn word_selection_at(&mut self, position: TerminalSelectionPoint) -> Option { - let row = self.row_for_line(position.row)?; - let range = word_columns_for_row(&row, position.column)?; - Some(TerminalSelection::range( - TerminalSelectionPoint { - row: position.row, - column: range.start, - }, - TerminalSelectionPoint { - row: position.row, - column: range.end.saturating_sub(1), - }, - )) - } - - fn update_selection_for_drag(&mut self, position: TerminalSelectionPoint) -> bool { - match self.selection_mode { - TerminalSelectionMode::Cell => self - .selection - .as_mut() - .is_some_and(|selection| selection.update_active(position)), - TerminalSelectionMode::Word => self.update_word_selection_for_drag(position), - TerminalSelectionMode::Line => self.update_line_selection_for_drag(position), - } - } - - fn update_word_selection_for_drag(&mut self, position: TerminalSelectionPoint) -> bool { - let Some(anchor) = self.selection_anchor else { - return false; - }; - let active = if let Some(active) = self.word_selection_at(position) { - active - } else if let Some(active) = self.empty_area_line_selection_at(position) { - active - } else { - return false; - }; - self.set_selection(selection_spanning_units(anchor, active)) - } - - fn empty_area_line_selection_at( - &mut self, - position: TerminalSelectionPoint, - ) -> Option { - let row = self.row_for_line(position.row)?; - line_selection_for_empty_area(&row, position, self.size) - } - - fn row_for_line(&self, row: i32) -> Option { - row_for_line(&self.term, self.size, self.theme, row) - } - - fn update_line_selection_for_drag(&mut self, position: TerminalSelectionPoint) -> bool { - let Some(anchor) = self.selection_anchor else { - return false; - }; - self.set_selection(selection_spanning_units( - anchor, - line_selection_at(position, self.size), - )) - } - - fn set_selection(&mut self, selection: TerminalSelection) -> bool { - if self.selection == Some(selection) { - return false; - } - self.selection = Some(selection); - true - } - - fn clear_selection(&mut self) { - self.selection = None; - self.selecting = false; - self.selection_mode = TerminalSelectionMode::Cell; - self.selection_anchor = None; - } -} - -impl Drop for TerminalSession { - fn drop(&mut self) { - self.shutdown(); - } -} - -impl Focusable for TerminalSession { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EntityInputHandler for TerminalSession { - fn text_for_range( - &mut self, - range_utf16: Range, - actual_range: &mut Option>, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - actual_range.replace(range_utf16); - Some(String::new()) - } - - fn selected_text_range( - &mut self, - _ignore_disabled_input: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(UTF16Selection { - range: 0..0, - reversed: false, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.ime_marked_text - .as_ref() - .map(|text| 0..text.chars().map(char::len_utf16).sum()) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.ime_marked_text = None; - } - - fn replace_text_in_range( - &mut self, - _range_utf16: Option>, - new_text: &str, - _window: &mut Window, - _cx: &mut Context, - ) { - self.ime_marked_text = None; - self.write_text(new_text); - } - - fn replace_and_mark_text_in_range( - &mut self, - _range_utf16: Option>, - new_text: &str, - _new_selected_range_utf16: Option>, - _window: &mut Window, - _cx: &mut Context, - ) { - if new_text.is_empty() { - self.ime_marked_text = None; - } else { - self.ime_marked_text = Some(new_text.to_string()); - } - } - - fn bounds_for_range( - &mut self, - _range_utf16: Range, - bounds: gpui::Bounds, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - Some(bounds) - } - - fn character_index_for_point( - &mut self, - _point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(0) - } -} - -struct TerminalProcess { - master: Box, - writer: Box, - child: Box, - exited: bool, -} - -impl TerminalProcess { - fn new( - master: Box, - writer: Box, - child: Box, - ) -> Self { - Self { - master, - writer, - child, - exited: false, - } - } - - fn resize(&mut self, size: TerminalSize) { - let _ = self.master.resize(pty_size(size)); - } - - fn write(&mut self, bytes: &[u8]) { - let _ = self.writer.write_all(bytes); - let _ = self.writer.flush(); - } - - fn current_directory(&self) -> Option { - process_current_directory(self.child.process_id()?) - } - - fn try_wait(&mut self) -> bool { - match self.child.try_wait() { - Ok(Some(_)) => { - self.exited = true; - true - } - Ok(None) | Err(_) => false, - } - } - - fn terminate(&mut self) { - if self.exited { - return; - } - let _ = self.child.kill(); - let _ = self.child.wait(); - self.exited = true; - } -} - -#[cfg(target_os = "linux")] -fn process_current_directory(pid: u32) -> Option { - let cwd = std::fs::read_link(format!("/proc/{pid}/cwd")).ok()?; - (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) -} - -#[cfg(not(target_os = "linux"))] -fn process_current_directory(_pid: u32) -> Option { - None -} - -impl Drop for TerminalProcess { - fn drop(&mut self) { - self.terminate(); - } -} - -#[derive(Clone)] -struct TerminalEventProxy { - tx: Sender, -} - -impl EventListener for TerminalEventProxy { - fn send_event(&self, event: Event) { - let _ = self.tx.send(event); - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct TerminalSize { - columns: usize, - rows: usize, - cell_width: u16, - cell_height: u16, -} - -impl Default for TerminalSize { - fn default() -> Self { - Self { - columns: DEFAULT_COLUMNS, - rows: DEFAULT_ROWS, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - } - } -} - -impl Dimensions for TerminalSize { - fn total_lines(&self) -> usize { - self.rows - } - - fn screen_lines(&self) -> usize { - self.rows - } - - fn columns(&self) -> usize { - self.columns - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct TerminalSelectionPoint { - row: i32, - column: usize, -} - -impl TerminalSelectionPoint { - fn from_viewport(row: usize, column: usize, display_offset: usize, size: TerminalSize) -> Self { - Self { - row: row.min(size.rows.saturating_sub(1)) as i32 - display_offset as i32, - column: column.min(size.columns.saturating_sub(1)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct TerminalViewportPoint { - row: usize, - column: usize, -} - -impl TerminalViewportPoint { - fn new(row: usize, column: usize, size: TerminalSize) -> Self { - Self { - row: row.min(size.rows.saturating_sub(1)), - column: column.min(size.columns.saturating_sub(1)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct TerminalSelection { - anchor: TerminalSelectionPoint, - active: TerminalSelectionPoint, - empty: bool, -} - -impl TerminalSelection { - fn new(position: TerminalSelectionPoint) -> Self { - Self { - anchor: position, - active: position, - empty: true, - } - } - - fn range(anchor: TerminalSelectionPoint, active: TerminalSelectionPoint) -> Self { - Self { - anchor, - active, - empty: false, - } - } - - fn update_active(&mut self, position: TerminalSelectionPoint) -> bool { - let empty = self.anchor == position; - if self.active == position && self.empty == empty { - return false; - } - self.active = position; - self.empty = empty; - true - } - - fn is_empty(&self) -> bool { - self.empty - } - - fn ordered_points(self) -> (TerminalSelectionPoint, TerminalSelectionPoint) { - if self.anchor <= self.active { - (self.anchor, self.active) - } else { - (self.active, self.anchor) - } - } - - fn normalized(self, size: TerminalSize) -> Option { - if self.is_empty() || size.rows == 0 || size.columns == 0 { - return None; - } - let (start, end) = self.ordered_points(); - Some(TerminalNormalizedSelection { start, end }) - } -} - -fn line_selection_at(position: TerminalSelectionPoint, size: TerminalSize) -> TerminalSelection { - TerminalSelection::range( - TerminalSelectionPoint { - row: position.row, - column: 0, - }, - TerminalSelectionPoint { - row: position.row, - column: size.columns.saturating_sub(1), - }, - ) -} - -fn selection_spanning_units( - anchor: TerminalSelection, - active: TerminalSelection, -) -> TerminalSelection { - let (anchor_start, anchor_end) = anchor.ordered_points(); - let (active_start, active_end) = active.ordered_points(); - if active_start < anchor_start { - TerminalSelection::range(anchor_end, active_start) - } else { - TerminalSelection::range(anchor_start, active_end) - } -} - -fn line_selection_for_empty_area( - row: &TerminalRow, - position: TerminalSelectionPoint, - size: TerminalSize, -) -> Option { - (!row_has_text_at_or_after(row, position.column)).then(|| line_selection_at(position, size)) -} - -fn word_columns_for_row(row: &TerminalRow, column: usize) -> Option> { - let cell_index = cell_index_for_column(row, column)?; - if !is_terminal_word_cell(&row.cells[cell_index]) { - return None; - } - - let mut start_index = cell_index; - while start_index > 0 && is_terminal_word_cell(&row.cells[start_index - 1]) { - start_index -= 1; - } - - let mut end_index = cell_index + 1; - while end_index < row.cells.len() && is_terminal_word_cell(&row.cells[end_index]) { - end_index += 1; - } - - let start = row.cells[start_index].column; - let last = &row.cells[end_index - 1]; - let end = last.column + last.width; - (start < end).then_some(start..end) -} - -fn cell_index_for_column(row: &TerminalRow, column: usize) -> Option { - row.cells.iter().position(|cell| { - let end = cell.column + cell.width; - cell.column <= column && column < end - }) -} - -fn is_terminal_word_cell(cell: &TerminalCell) -> bool { - cell.text.chars().any(|ch| !ch.is_whitespace()) -} - -fn row_has_text_at_or_after(row: &TerminalRow, column: usize) -> bool { - row.cells.iter().any(|cell| { - let end = cell.column + cell.width; - end > column && is_terminal_word_cell(cell) - }) -} - -fn text_for_selection( - term: &Term, - size: TerminalSize, - theme: TerminalTheme, - selection: TerminalSelection, - trim_trailing_spaces: bool, -) -> Option { - let selection = selection.normalized(size)?; - let mut lines = Vec::new(); - - for row_index in selection.start.row..=selection.end.row { - let row = row_for_line(term, size, theme, row_index)?; - let start_column = if row_index == selection.start.row { - selection.start.column - } else { - 0 - }; - let end_column = if row_index == selection.end.row { - selection.end.column.saturating_add(1).min(size.columns) - } else { - size.columns - }; - lines.push(text_for_columns( - &row, - start_column, - end_column, - trim_trailing_spaces, - )); - } - - Some(lines.join("\n")) -} - -fn row_for_line( - term: &Term, - size: TerminalSize, - theme: TerminalTheme, - row: i32, -) -> Option { - let line = Line(row); - let grid = term.grid(); - if line < grid.topmost_line() || line > grid.bottommost_line() { - return None; - } - let cells = (0..size.columns) - .map(|column| grid[line][Column(column)].clone()) - .collect(); - Some(snapshot_row(cells, term.colors(), theme)) -} - -fn should_clear_selection_after_output( - mode_before: TermMode, - mode_after: TermMode, - selected_before: Option<&str>, - selected_after: Option<&str>, -) -> bool { - if mode_before.contains(TermMode::ALT_SCREEN) != mode_after.contains(TermMode::ALT_SCREEN) { - return true; - } - selected_before.is_none() || selected_before != selected_after -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct TerminalNormalizedSelection { - start: TerminalSelectionPoint, - end: TerminalSelectionPoint, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TerminalMouseWheelDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TerminalMouseEventKind { - Press(TerminalMouseButton), - Move(TerminalMouseButton), - Release(TerminalMouseButton), - Wheel(TerminalMouseWheelDirection), -} - -fn new_term(size: TerminalSize, event_tx: Sender) -> Term { - Term::new( - Config { - scrolling_history: 10_000, - ..Config::default() - }, - &size, - TerminalEventProxy { tx: event_tx }, - ) -} - -fn pty_size(size: TerminalSize) -> PtySize { - PtySize { - rows: size.rows as u16, - cols: size.columns as u16, - pixel_width: size.columns.saturating_mul(size.cell_width as usize) as u16, - pixel_height: size.rows.saturating_mul(size.cell_height as usize) as u16, - } -} - -fn spawn_reader(mut reader: Box, output_tx: Sender>) { - std::thread::spawn(move || { - let mut buffer = [0u8; READ_BUFFER_SIZE]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(count) => { - if output_tx.send(buffer[..count].to_vec()).is_err() { - return; - } - } - Err(_) => break, - } - } - let _ = output_tx.send(Vec::new()); - }); -} - -struct TerminalBellCommand { - program: &'static str, - args: &'static [&'static str], -} - -fn play_terminal_bell() { - let _ = std::thread::Builder::new() - .name("terminal-bell".to_string()) - .spawn(|| { - for command in terminal_bell_commands() { - let Ok(status) = ProcessCommand::new(command.program) - .args(command.args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - else { - continue; - }; - if status.success() { - return; - } - } - }); -} - -#[cfg(any(target_os = "linux", target_os = "freebsd"))] -fn terminal_bell_commands() -> &'static [TerminalBellCommand] { - &[ - TerminalBellCommand { - program: "canberra-gtk-play", - args: &["-i", "bell", "-d", "kosmos"], - }, - TerminalBellCommand { - program: "paplay", - args: &["/usr/share/sounds/freedesktop/stereo/bell.oga"], - }, - TerminalBellCommand { - program: "pw-play", - args: &["/usr/share/sounds/freedesktop/stereo/bell.oga"], - }, - ] -} - -#[cfg(target_os = "macos")] -fn terminal_bell_commands() -> &'static [TerminalBellCommand] { - &[ - TerminalBellCommand { - program: "afplay", - args: &["/System/Library/Sounds/Tink.aiff"], - }, - TerminalBellCommand { - program: "osascript", - args: &["-e", "beep"], - }, - ] -} - -#[cfg(target_os = "windows")] -fn terminal_bell_commands() -> &'static [TerminalBellCommand] { - &[TerminalBellCommand { - program: "powershell", - args: &[ - "-NoProfile", - "-NonInteractive", - "-Command", - "[console]::beep(880,120)", - ], - }] -} - -#[cfg(not(any( - target_os = "linux", - target_os = "freebsd", - target_os = "macos", - target_os = "windows" -)))] -fn terminal_bell_commands() -> &'static [TerminalBellCommand] { - &[] -} - -pub fn discover_shells() -> Vec { - let mut candidates = Vec::new(); - candidates.extend(environment_shell_candidates()); - candidates.extend(platform_shell_candidates()); - candidates.push(fallback_shell_profile().path); - - let mut shells = candidates - .into_iter() - .filter_map(ShellProfile::new) - .filter(is_selectable_shell_profile) - .collect::>(); - dedupe_shell_profiles_by_label(&mut shells); - hide_redundant_sh_profile(&mut shells); - shells -} - -fn dedupe_shell_profiles_by_label(shells: &mut Vec) { - let mut seen_labels = HashSet::new(); - shells.retain(|shell| seen_labels.insert(shell.label.clone())); -} - -fn hide_redundant_sh_profile(shells: &mut Vec) { - if shells.iter().any(|shell| shell.label != "sh") { - shells.retain(|shell| shell.label != "sh"); - } -} - -fn is_selectable_shell_profile(shell: &ShellProfile) -> bool { - is_selectable_shell_name(&shell.label) -} - -#[cfg(unix)] -fn is_selectable_shell_name(name: &str) -> bool { - !matches!( - name, - "false" - | "git-shell" - | "nologin" - | "rbash" - | "rksh" - | "rsh" - | "rzsh" - | "systemd-home-fallback-shell" - ) -} - -#[cfg(not(unix))] -fn is_selectable_shell_name(_name: &str) -> bool { - true -} - -fn environment_shell_candidates() -> Vec { - let mut candidates = Vec::new(); - if let Some(shell) = std::env::var_os("SHELL") { - candidates.push(PathBuf::from(shell)); - } - #[cfg(windows)] - if let Some(shell) = std::env::var_os("COMSPEC") { - candidates.push(PathBuf::from(shell)); - } - candidates -} - -#[cfg(unix)] -fn platform_shell_candidates() -> Vec { - let mut candidates = shells_from_etc_shells(); - candidates.extend(common_unix_shell_candidates()); - candidates -} - -#[cfg(windows)] -fn platform_shell_candidates() -> Vec { - let mut candidates = Vec::new(); - if let Some(program_files) = std::env::var_os("ProgramFiles") { - candidates.push(PathBuf::from(program_files).join("PowerShell/7/pwsh.exe")); - } - let system_root = std::env::var_os("SystemRoot") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); - candidates.push(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe")); - candidates.push(system_root.join(r"System32\cmd.exe")); - candidates -} - -#[cfg(not(any(unix, windows)))] -fn platform_shell_candidates() -> Vec { - Vec::new() -} - -#[cfg(unix)] -fn shells_from_etc_shells() -> Vec { - let Ok(contents) = std::fs::read_to_string("/etc/shells") else { - return Vec::new(); - }; - contents - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(PathBuf::from) - .collect() -} - -#[cfg(unix)] -fn common_unix_shell_candidates() -> Vec { - [ - "/bin/zsh", - "/usr/bin/zsh", - "/bin/fish", - "/usr/bin/fish", - "/bin/bash", - "/usr/bin/bash", - "/bin/sh", - "/usr/bin/sh", - ] - .into_iter() - .map(PathBuf::from) - .collect() -} - -fn is_executable_file(path: &Path) -> bool { - let Ok(metadata) = path.metadata() else { - return false; - }; - if !metadata.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true - } -} - -#[cfg(unix)] -fn fallback_shell_profile() -> ShellProfile { - let path = PathBuf::from("/bin/sh"); - ShellProfile { - id: path.to_string_lossy().into_owned(), - label: "sh".to_string(), - path, - } -} - -#[cfg(windows)] -fn fallback_shell_profile() -> ShellProfile { - let path = std::env::var_os("COMSPEC") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows\System32\cmd.exe")); - ShellProfile { - id: path.to_string_lossy().into_owned(), - label: path - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("cmd") - .to_string(), - path, - } -} - -#[cfg(not(any(unix, windows)))] -fn fallback_shell_profile() -> ShellProfile { - let path = PathBuf::from("sh"); - ShellProfile { - id: path.to_string_lossy().into_owned(), - label: "sh".to_string(), - path, - } -} - -fn snapshot_rows( - content: alacritty_terminal::term::RenderableContent<'_>, - size: TerminalSize, - theme: TerminalTheme, -) -> Vec { - let mut cells = vec![vec![Cell::default(); size.columns]; size.rows]; - let display_offset = content.display_offset; - let colors = content.colors; - for indexed in content.display_iter { - let row = indexed.point.line.0 + display_offset as i32; - if row < 0 || row as usize >= size.rows { - continue; - } - let column = indexed.point.column.0; - if column >= size.columns { - continue; - } - cells[row as usize][column] = indexed.cell.clone(); - } - - cells - .into_iter() - .map(|row| snapshot_row(row, colors, theme)) - .collect() -} - -fn snapshot_row( - cells: Vec, - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> TerminalRow { - let mut text = String::new(); - let mut runs: Vec = Vec::new(); - let mut cell_runs: Vec = Vec::new(); - let mut terminal_cells: Vec = Vec::new(); - let total_columns = cells.len(); - let mut occupied_columns = 0; - let mut previous_cell_was_wide = false; - - for cell in cells { - let is_wide_spacer = cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER); - let style = style_for_cell(&cell, colors, theme); - if is_wide_spacer { - if previous_cell_was_wide { - previous_cell_was_wide = false; - continue; - } - push_terminal_cell( - &mut terminal_cells, - " ".to_string(), - style, - occupied_columns, - 1, - ); - push_cell_run(&mut cell_runs, " ".to_string(), style, occupied_columns, 1); - occupied_columns += 1; - previous_cell_was_wide = false; - continue; - } - - let start = text.len(); - let cell_text = text_for_cell(&cell); - text.push_str(&cell_text); - let end = text.len(); - push_text_run(&mut runs, start..end, style); - - let is_wide = cell.flags.contains(Flags::WIDE_CHAR); - let width = if is_wide && occupied_columns + 1 < total_columns { - 2 - } else { - 1 - }; - push_terminal_cell( - &mut terminal_cells, - cell_text.clone(), - style, - occupied_columns, - width, - ); - push_cell_run(&mut cell_runs, cell_text, style, occupied_columns, width); - occupied_columns += width; - previous_cell_was_wide = is_wide; - } - - TerminalRow { - text, - runs, - cell_runs, - cells: terminal_cells, - } -} - -fn push_terminal_cell( - cells: &mut Vec, - text: String, - style: TerminalStyle, - column: usize, - width: usize, -) { - cells.push(TerminalCell { - text, - style, - column, - width, - }); -} - -fn push_cell_run( - cell_runs: &mut Vec, - text: String, - style: TerminalStyle, - column: usize, - width: usize, -) { - if let Some(last) = cell_runs.last_mut() - && last.style == style - && last.column + last.width == column - { - last.text.push_str(&text); - last.width += width; - return; - } - - cell_runs.push(TerminalCellRun { - text, - style, - column, - width, - }); -} - -fn text_for_cell(cell: &Cell) -> String { - if cell.flags.contains(Flags::HIDDEN) { - return " ".to_string(); - } - - let mut text = String::new(); - text.push(cell.c); - for ch in cell.zerowidth().into_iter().flatten() { - text.push(*ch); - } - text -} - -fn push_text_run(runs: &mut Vec, range: Range, style: TerminalStyle) { - if range.is_empty() { - return; - } - if let Some(last) = runs.last_mut() - && last.style == style - && last.range.end == range.start - { - last.range.end = range.end; - return; - } - runs.push(TerminalTextRun { range, style }); -} - -fn snapshot_cursor( - cursor: alacritty_terminal::term::RenderableCursor, - display_offset: usize, - size: TerminalSize, -) -> Option { - let row = cursor.point.line.0 + display_offset as i32; - if row < 0 || row as usize >= size.rows { - return None; - } - let shape = match cursor.shape { - CursorShape::Block => TerminalCursorShape::Block, - CursorShape::Underline => TerminalCursorShape::Underline, - CursorShape::Beam => TerminalCursorShape::Beam, - CursorShape::HollowBlock => TerminalCursorShape::HollowBlock, - CursorShape::Hidden => TerminalCursorShape::Hidden, - }; - if shape == TerminalCursorShape::Hidden { - return None; - } - Some(TerminalCursor { - row: row as usize, - column: cursor.point.column.0.min(size.columns.saturating_sub(1)), - shape, - }) -} - -fn selection_ranges( - selection: Option, - size: TerminalSize, - display_offset: usize, -) -> Vec { - let Some(selection) = selection.and_then(|selection| selection.normalized(size)) else { - return Vec::new(); - }; - let viewport_start = -(display_offset as i32); - let viewport_end = viewport_start + size.rows.saturating_sub(1) as i32; - let start_row = selection.start.row.max(viewport_start); - let end_row = selection.end.row.min(viewport_end); - if start_row > end_row { - return Vec::new(); - } - - (start_row..=end_row) - .filter_map(|row| { - let start_column = if row == selection.start.row { - selection.start.column - } else { - 0 - }; - let end_column = if row == selection.end.row { - selection.end.column.saturating_add(1).min(size.columns) - } else { - size.columns - }; - let width = end_column.saturating_sub(start_column); - (width > 0).then_some(TerminalSelectionRange { - row: (row + display_offset as i32) as usize, - column: start_column, - width, - }) - }) - .collect() -} - -fn text_for_columns( - row: &TerminalRow, - start_column: usize, - end_column: usize, - trim_trailing_spaces: bool, -) -> String { - if start_column >= end_column { - return String::new(); - } - - let mut text = String::new(); - for cell in &row.cells { - let cell_end = cell.column + cell.width; - if cell.column >= end_column || cell_end <= start_column { - continue; - } - text.push_str(&cell.text); - } - if trim_trailing_spaces { - text.trim_end_matches(' ').to_string() - } else { - text - } -} - -fn style_for_cell( - cell: &Cell, - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> TerminalStyle { - let mut foreground = color_for(cell.fg, colors, theme) - .unwrap_or_else(|| rgb_to_terminal(default_foreground(theme))); - let mut background = color_for(cell.bg, colors, theme) - .unwrap_or_else(|| rgb_to_terminal(default_background(theme))); - if cell.flags.contains(Flags::INVERSE) { - std::mem::swap(&mut foreground, &mut background); - } - TerminalStyle { - foreground, - background, - bold: cell.flags.contains(Flags::BOLD), - italic: cell.flags.contains(Flags::ITALIC), - underline: cell.flags.intersects(Flags::ALL_UNDERLINES), - dim: cell.flags.contains(Flags::DIM), - strikeout: cell.flags.contains(Flags::STRIKEOUT), - } -} - -fn color_for( - color: AnsiColor, - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> Option { - match color { - AnsiColor::Spec(rgb) => Some(rgb_to_terminal(rgb)), - AnsiColor::Indexed(index) => { - rgb_for_index(index as usize, colors, theme).map(rgb_to_terminal) - } - AnsiColor::Named(named) => rgb_for_named_color(named, colors, theme).map(rgb_to_terminal), - } -} - -const DARK_FALLBACK_FOREGROUND: Rgb = Rgb { - r: 0xff, - g: 0xff, - b: 0xff, -}; -const DARK_FALLBACK_BACKGROUND: Rgb = Rgb { - r: 0x1c, - g: 0x1c, - b: 0x1f, -}; -const LIGHT_FALLBACK_FOREGROUND: Rgb = Rgb { - r: 0x00, - g: 0x00, - b: 0x00, -}; -const LIGHT_FALLBACK_BACKGROUND: Rgb = Rgb { - r: 0xff, - g: 0xff, - b: 0xff, -}; - -fn snapshot_cursor_color( - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> TerminalColor { - rgb_to_terminal( - rgb_for_named_color(NamedColor::Cursor, colors, theme) - .unwrap_or_else(|| default_foreground(theme)), - ) -} - -fn rgb_for_index( - index: usize, - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> Option { - if index < alacritty_terminal::term::color::COUNT - && let Some(color) = colors[index] - { - return Some(color); - } - - default_color_for_index(index, theme) -} - -fn rgb_for_named_color( - color: NamedColor, - colors: &alacritty_terminal::term::color::Colors, - theme: TerminalTheme, -) -> Option { - colors[color].or_else(|| default_named_color(color, theme)) -} - -fn default_foreground(theme: TerminalTheme) -> Rgb { - terminal_to_rgb(theme.foreground) -} - -fn default_background(theme: TerminalTheme) -> Rgb { - terminal_to_rgb(theme.background) -} - -fn default_named_color(color: NamedColor, theme: TerminalTheme) -> Option { - let value = match color { - NamedColor::Black => 0x241f31, - NamedColor::Red => 0xc01c28, - NamedColor::Green => 0x2ec27e, - NamedColor::Yellow => 0xf5c211, - NamedColor::Blue => 0x1e78e4, - NamedColor::Magenta => 0x9841bb, - NamedColor::Cyan => 0x0ab9dc, - NamedColor::White => 0xc0bfbc, - NamedColor::BrightBlack => 0x5e5c64, - NamedColor::BrightRed => 0xed333b, - NamedColor::BrightGreen => 0x57e389, - NamedColor::BrightYellow => 0xf8e45c, - NamedColor::BrightBlue => 0x51a1ff, - NamedColor::BrightMagenta => 0xc061cb, - NamedColor::BrightCyan => 0x4fd2fd, - NamedColor::BrightWhite => 0xf6f5f4, - NamedColor::Foreground | NamedColor::BrightForeground => { - return Some(default_foreground(theme)); - } - NamedColor::Background => return Some(default_background(theme)), - NamedColor::Cursor => return Some(default_foreground(theme)), - NamedColor::DimForeground => 0xa6a6a6, - NamedColor::DimBlack => 0x17141f, - NamedColor::DimRed => 0x7d121a, - NamedColor::DimGreen => 0x1e7e52, - NamedColor::DimYellow => 0x9f7e0b, - NamedColor::DimBlue => 0x144e94, - NamedColor::DimMagenta => 0x632a7a, - NamedColor::DimCyan => 0x07788f, - NamedColor::DimWhite => 0x7d7c7a, - }; - Some(rgb_from_u32(value)) -} - -fn default_color_for_index(index: usize, theme: TerminalTheme) -> Option { - if index < 16 { - return default_named_color( - match index { - 0 => NamedColor::Black, - 1 => NamedColor::Red, - 2 => NamedColor::Green, - 3 => NamedColor::Yellow, - 4 => NamedColor::Blue, - 5 => NamedColor::Magenta, - 6 => NamedColor::Cyan, - 7 => NamedColor::White, - 8 => NamedColor::BrightBlack, - 9 => NamedColor::BrightRed, - 10 => NamedColor::BrightGreen, - 11 => NamedColor::BrightYellow, - 12 => NamedColor::BrightBlue, - 13 => NamedColor::BrightMagenta, - 14 => NamedColor::BrightCyan, - _ => NamedColor::BrightWhite, - }, - theme, - ); - } - - if (16..=231).contains(&index) { - let index = index - 16; - let r = index / 36; - let g = (index % 36) / 6; - let b = index % 6; - let channel = |value: usize| if value == 0 { 0 } else { 55 + value as u8 * 40 }; - return Some(Rgb { - r: channel(r), - g: channel(g), - b: channel(b), - }); - } - - if (232..=255).contains(&index) { - let value = 8 + (index as u8 - 232) * 10; - return Some(Rgb { - r: value, - g: value, - b: value, - }); - } - - match index { - 256 => Some(default_foreground(theme)), - 257 => Some(default_background(theme)), - 258 => Some(default_foreground(theme)), - _ => None, - } -} - -fn rgb_to_terminal(rgb: Rgb) -> TerminalColor { - TerminalColor { - r: rgb.r, - g: rgb.g, - b: rgb.b, - } -} - -fn terminal_to_rgb(color: TerminalColor) -> Rgb { - Rgb { - r: color.r, - g: color.g, - b: color.b, - } -} - -fn rgb_from_u32(value: u32) -> Rgb { - Rgb { - r: ((value >> 16) & 0xff) as u8, - g: ((value >> 8) & 0xff) as u8, - b: (value & 0xff) as u8, - } -} - -fn encode_mouse_event( - kind: TerminalMouseEventKind, - position: TerminalViewportPoint, - modifiers: TerminalMouseModifiers, - mode: TermMode, -) -> Option> { - let code = mouse_event_code(kind, modifiers, mode.contains(TermMode::SGR_MOUSE)); - let column = position.column + 1; - let row = position.row + 1; - - if mode.contains(TermMode::SGR_MOUSE) { - let final_byte = match kind { - TerminalMouseEventKind::Release(_) => 'm', - TerminalMouseEventKind::Press(_) - | TerminalMouseEventKind::Move(_) - | TerminalMouseEventKind::Wheel(_) => 'M', - }; - return Some(format!("\x1b[<{code};{column};{row}{final_byte}").into_bytes()); - } - - encode_normal_mouse_event(code, column, row) -} - -fn mouse_event_code( - kind: TerminalMouseEventKind, - modifiers: TerminalMouseModifiers, - sgr_mouse: bool, -) -> u8 { - let mut code = match kind { - TerminalMouseEventKind::Press(button) => mouse_button_code(button), - TerminalMouseEventKind::Move(button) => mouse_button_code(button) + 32, - TerminalMouseEventKind::Release(button) if sgr_mouse => mouse_button_code(button), - TerminalMouseEventKind::Release(_) => 3, - TerminalMouseEventKind::Wheel(TerminalMouseWheelDirection::Up) => 64, - TerminalMouseEventKind::Wheel(TerminalMouseWheelDirection::Down) => 65, - }; - if modifiers.shift { - code += 4; - } - if modifiers.alt { - code += 8; - } - if modifiers.control { - code += 16; - } - code -} - -fn mouse_button_code(button: TerminalMouseButton) -> u8 { - match button { - TerminalMouseButton::Left => 0, - TerminalMouseButton::Middle => 1, - TerminalMouseButton::Right => 2, - } -} - -fn encode_normal_mouse_event(code: u8, column: usize, row: usize) -> Option> { - let encoded_code = code.checked_add(32)?; - let encoded_column = u8::try_from(column).ok()?.checked_add(32)?; - let encoded_row = u8::try_from(row).ok()?.checked_add(32)?; - - Some(vec![ - 0x1b, - b'[', - b'M', - encoded_code, - encoded_column, - encoded_row, - ]) -} - -fn encode_key_input(input: &TerminalKeyInput, mode: TermMode) -> Option { - if input.platform { - return None; - } - - let key = input.key.as_str(); - let encoded = match key { - "enter" if input.alt => "\x1b\r".to_string(), - "enter" => "\r".to_string(), - "tab" if input.shift => "\x1b[Z".to_string(), - "tab" => "\t".to_string(), - "escape" | "esc" => "\x1b".to_string(), - "backspace" => { - if input.alt { - "\x1b\x7f".to_string() - } else { - "\x7f".to_string() - } - } - "left" => cursor_key('D', input, mode), - "right" => cursor_key('C', input, mode), - "up" => cursor_key('A', input, mode), - "down" => cursor_key('B', input, mode), - "home" => cursor_key('H', input, mode), - "end" => cursor_key('F', input, mode), - "delete" => csi_tilde_key(3, input), - "insert" => csi_tilde_key(2, input), - "pageup" | "page_up" => csi_tilde_key(5, input), - "pagedown" | "page_down" => csi_tilde_key(6, input), - "f1" => function_key("P", 11, input), - "f2" => function_key("Q", 12, input), - "f3" => function_key("R", 13, input), - "f4" => function_key("S", 14, input), - "f5" => csi_tilde_key(15, input), - "f6" => csi_tilde_key(17, input), - "f7" => csi_tilde_key(18, input), - "f8" => csi_tilde_key(19, input), - "f9" => csi_tilde_key(20, input), - "f10" => csi_tilde_key(21, input), - "f11" => csi_tilde_key(23, input), - "f12" => csi_tilde_key(24, input), - _ => text_key(input)?, - }; - Some(encoded) -} - -fn cursor_key(final_byte: char, input: &TerminalKeyInput, mode: TermMode) -> String { - if let Some(modifier) = modifier_parameter(input) { - return format!("\x1b[1;{modifier}{final_byte}"); - } - if mode.contains(TermMode::APP_CURSOR) { - format!("\x1bO{final_byte}") - } else { - format!("\x1b[{final_byte}") - } -} - -fn csi_tilde_key(number: u8, input: &TerminalKeyInput) -> String { - if let Some(modifier) = modifier_parameter(input) { - format!("\x1b[{number};{modifier}~") - } else { - format!("\x1b[{number}~") - } -} - -fn function_key(ss3_final: &str, csi_number: u8, input: &TerminalKeyInput) -> String { - if let Some(modifier) = modifier_parameter(input) { - format!("\x1b[{csi_number};{modifier}~") - } else { - format!("\x1bO{ss3_final}") - } -} - -fn modifier_parameter(input: &TerminalKeyInput) -> Option { - let mut value = 1; - if input.shift { - value += 1; - } - if input.alt { - value += 2; - } - if input.control { - value += 4; - } - (value > 1).then_some(value) -} - -fn text_key(input: &TerminalKeyInput) -> Option { - if input.control { - return control_key(input).map(|ch| ch.to_string()); - } - let mut text = input.text.clone()?; - if input.alt { - text.insert(0, '\x1b'); - } - Some(text) -} - -fn control_key(input: &TerminalKeyInput) -> Option { - let key = input.key.as_bytes(); - if key.len() != 1 { - return match input.key.as_str() { - "space" => Some('\0'), - "[" => Some('\x1b'), - "\\" => Some('\x1c'), - "]" => Some('\x1d'), - "^" => Some('\x1e'), - "_" => Some('\x1f'), - _ => None, - }; - } - let byte = key[0].to_ascii_lowercase(); - if byte.is_ascii_lowercase() { - return Some((byte - b'a' + 1) as char); - } - match byte { - b'@' | b'`' | b' ' => Some('\0'), - b'[' => Some('\x1b'), - b'\\' => Some('\x1c'), - b']' => Some('\x1d'), - b'^' => Some('\x1e'), - b'_' | b'/' => Some('\x1f'), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alacritty_terminal::event::VoidListener; - - fn test_shell_profile(path: &str, label: &str) -> ShellProfile { - ShellProfile { - id: path.to_string(), - label: label.to_string(), - path: PathBuf::from(path), - } - } - - fn shell_labels(shells: &[ShellProfile]) -> Vec { - shells.iter().map(|shell| shell.label.clone()).collect() - } - - #[test] - fn encodes_common_control_keys() { - let input = TerminalKeyInput { - key: "c".to_string(), - control: true, - ..Default::default() - }; - assert_eq!( - encode_key_input(&input, TermMode::default()), - Some("\x03".to_string()) - ); - } - - #[test] - fn encodes_modified_cursor_keys() { - let input = TerminalKeyInput { - key: "up".to_string(), - shift: true, - control: true, - ..Default::default() - }; - assert_eq!( - encode_key_input(&input, TermMode::default()), - Some("\x1b[1;6A".to_string()) - ); - } - - #[test] - fn encodes_alt_enter() { - let input = TerminalKeyInput { - key: "enter".to_string(), - alt: true, - ..Default::default() - }; - - assert_eq!( - encode_key_input(&input, TermMode::default()), - Some("\x1b\r".to_string()) - ); - } - - #[test] - fn dedupes_shell_profiles() { - let shells = discover_shells(); - let mut ids = HashSet::new(); - let mut labels = HashSet::new(); - for shell in shells { - assert!(ids.insert(shell.id)); - assert!(labels.insert(shell.label)); - } - } - - #[test] - fn dedupes_shell_profiles_by_display_label() { - let mut shells = vec![ - test_shell_profile("/usr/bin/bash", "bash"), - test_shell_profile("/bin/bash", "bash"), - test_shell_profile("/usr/bin/fish", "fish"), - ]; - - dedupe_shell_profiles_by_label(&mut shells); - - assert_eq!(shell_labels(&shells), ["bash", "fish"]); - } - - #[test] - fn hides_sh_when_named_shells_exist() { - let mut shells = vec![ - test_shell_profile("/bin/sh", "sh"), - test_shell_profile("/usr/bin/bash", "bash"), - ]; - - hide_redundant_sh_profile(&mut shells); - - assert_eq!(shell_labels(&shells), ["bash"]); - } - - #[cfg(unix)] - #[test] - fn filters_non_interactive_unix_shell_names() { - assert!(!is_selectable_shell_name("rbash")); - assert!(!is_selectable_shell_name("git-shell")); - assert!(!is_selectable_shell_name("systemd-home-fallback-shell")); - assert!(is_selectable_shell_name("bash")); - assert!(is_selectable_shell_name("fish")); - } - - #[test] - fn default_palette_matches_gnome_console_standard_livery() { - let theme = TerminalTheme::for_palette(TerminalPalette::Dark); - - assert_eq!( - default_named_color(NamedColor::Green, theme), - Some(rgb_from_u32(0x2ec27e)) - ); - assert_eq!( - default_named_color(NamedColor::BrightGreen, theme), - Some(rgb_from_u32(0x57e389)) - ); - } - - #[test] - fn default_foreground_and_background_follow_terminal_theme() { - let theme = TerminalTheme::new( - TerminalPalette::Dark, - TerminalColor::rgb(0xe5e5e5), - TerminalColor::rgb(0x161616), - ); - - assert_eq!( - default_named_color(NamedColor::Background, theme), - Some(rgb_from_u32(0x161616)) - ); - assert_eq!( - default_named_color(NamedColor::Foreground, theme), - Some(rgb_from_u32(0xe5e5e5)) - ); - assert_eq!( - default_named_color(NamedColor::Cursor, theme), - Some(rgb_from_u32(0xe5e5e5)) - ); - assert_eq!( - default_color_for_index(257, theme), - Some(rgb_from_u32(0x161616)) - ); - } - - #[test] - fn current_palette_overrides_defaults() { - let theme = TerminalTheme::for_palette(TerminalPalette::Dark); - let mut colors = alacritty_terminal::term::color::Colors::default(); - let custom_green = rgb_from_u32(0x00ff7f); - colors[NamedColor::Green] = Some(custom_green); - colors[2] = Some(custom_green); - - assert_eq!( - rgb_for_named_color(NamedColor::Green, &colors, theme), - Some(custom_green) - ); - assert_eq!(rgb_for_index(2, &colors, theme), Some(custom_green)); - } - - #[test] - fn encodes_sgr_mouse_events() { - let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE; - let position = TerminalViewportPoint { row: 2, column: 4 }; - - assert_eq!( - encode_mouse_event( - TerminalMouseEventKind::Press(TerminalMouseButton::Left), - position, - TerminalMouseModifiers::default(), - mode, - ), - Some(b"\x1b[<0;5;3M".to_vec()) - ); - assert_eq!( - encode_mouse_event( - TerminalMouseEventKind::Release(TerminalMouseButton::Left), - position, - TerminalMouseModifiers { - control: true, - ..Default::default() - }, - mode, - ), - Some(b"\x1b[<16;5;3m".to_vec()) - ); - assert_eq!( - encode_mouse_event( - TerminalMouseEventKind::Wheel(TerminalMouseWheelDirection::Down), - position, - TerminalMouseModifiers::default(), - mode, - ), - Some(b"\x1b[<65;5;3M".to_vec()) - ); - } - - #[test] - fn computes_selection_ranges_across_rows() { - let size = TerminalSize { - columns: 5, - rows: 3, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 3 }, - TerminalSelectionPoint { row: 2, column: 1 }, - ); - - assert_eq!( - selection_ranges(Some(selection), size, 0), - vec![ - TerminalSelectionRange { - row: 0, - column: 3, - width: 2, - }, - TerminalSelectionRange { - row: 1, - column: 0, - width: 5, - }, - TerminalSelectionRange { - row: 2, - column: 0, - width: 2, - }, - ] - ); - } - - #[test] - fn computes_selection_range_for_single_cell() { - let size = TerminalSize { - columns: 5, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 2 }, - TerminalSelectionPoint { row: 0, column: 2 }, - ); - - assert_eq!( - selection_ranges(Some(selection), size, 0), - vec![TerminalSelectionRange { - row: 0, - column: 2, - width: 1, - }] - ); - } - - #[test] - fn selection_ranges_follow_display_offset() { - let size = TerminalSize { - columns: 5, - rows: 3, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 1 }, - TerminalSelectionPoint { row: 0, column: 3 }, - ); - - assert_eq!( - selection_ranges(Some(selection), size, 0), - vec![TerminalSelectionRange { - row: 0, - column: 1, - width: 3, - }] - ); - assert_eq!( - selection_ranges(Some(selection), size, 2), - vec![TerminalSelectionRange { - row: 2, - column: 1, - width: 3, - }] - ); - assert!(selection_ranges(Some(selection), size, 3).is_empty()); - } - - #[test] - fn selection_text_reflects_terminal_content_changes() { - let size = TerminalSize { - columns: 8, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let theme = TerminalTheme::for_palette(TerminalPalette::Dark); - let mut term = Term::new(Config::default(), &size, VoidListener); - let mut parser: Processor = Processor::new(); - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 0 }, - TerminalSelectionPoint { row: 0, column: 4 }, - ); - - parser.advance(&mut term, b"hello"); - let before = text_for_selection(&term, size, theme, selection, false); - parser.advance(&mut term, b"\rhullo"); - let after = text_for_selection(&term, size, theme, selection, false); - - assert_eq!(before.as_deref(), Some("hello")); - assert_eq!(after.as_deref(), Some("hullo")); - assert_ne!(before, after); - } - - #[test] - fn selection_text_keeps_spaces_for_change_detection() { - let size = TerminalSize { - columns: 4, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let theme = TerminalTheme::for_palette(TerminalPalette::Dark); - let term = Term::new(Config::default(), &size, VoidListener); - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 0 }, - TerminalSelectionPoint { row: 0, column: 2 }, - ); - - assert_eq!( - text_for_selection(&term, size, theme, selection, false).as_deref(), - Some(" ") - ); - assert_eq!( - text_for_selection(&term, size, theme, selection, true).as_deref(), - Some("") - ); - } - - #[test] - fn selection_clears_when_alt_screen_toggles() { - let mode_before = TermMode::default(); - let mode_after = mode_before | TermMode::ALT_SCREEN; - - assert!(should_clear_selection_after_output( - mode_before, - mode_after, - Some("same"), - Some("same") - )); - } - - #[test] - fn alt_screen_escape_triggers_selection_clear_rule() { - let size = TerminalSize { - columns: 8, - rows: 2, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let theme = TerminalTheme::for_palette(TerminalPalette::Dark); - let mut term = Term::new(Config::default(), &size, VoidListener); - let mut parser: Processor = Processor::new(); - let selection = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 0 }, - TerminalSelectionPoint { row: 0, column: 3 }, - ); - - parser.advance(&mut term, b"same"); - let mode_before = *term.mode(); - let selected_before = text_for_selection(&term, size, theme, selection, false); - parser.advance(&mut term, b"\x1b[?1049h"); - let selected_after = text_for_selection(&term, size, theme, selection, false); - - assert!(term.mode().contains(TermMode::ALT_SCREEN)); - assert!(should_clear_selection_after_output( - mode_before, - *term.mode(), - selected_before.as_deref(), - selected_after.as_deref(), - )); - } - - #[test] - fn spans_selection_units_forward_and_backward() { - let size = TerminalSize { - columns: 12, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let start_word = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 2 }, - TerminalSelectionPoint { row: 0, column: 4 }, - ); - let end_word = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 8 }, - TerminalSelectionPoint { row: 0, column: 10 }, - ); - - assert_eq!( - selection_ranges( - Some(selection_spanning_units(start_word, end_word)), - size, - 0 - ), - vec![TerminalSelectionRange { - row: 0, - column: 2, - width: 9, - }] - ); - assert_eq!( - selection_ranges( - Some(selection_spanning_units(end_word, start_word)), - size, - 0 - ), - vec![TerminalSelectionRange { - row: 0, - column: 2, - width: 9, - }] - ); - } - - #[test] - fn spans_line_units_forward_and_backward() { - let size = TerminalSize { - columns: 5, - rows: 3, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let middle_line = line_selection_at(TerminalSelectionPoint { row: 1, column: 2 }, size); - let last_line = line_selection_at(TerminalSelectionPoint { row: 2, column: 1 }, size); - let first_line = line_selection_at(TerminalSelectionPoint { row: 0, column: 4 }, size); - - assert_eq!( - selection_ranges( - Some(selection_spanning_units(middle_line, last_line)), - size, - 0 - ), - vec![ - TerminalSelectionRange { - row: 1, - column: 0, - width: 5, - }, - TerminalSelectionRange { - row: 2, - column: 0, - width: 5, - }, - ] - ); - assert_eq!( - selection_ranges( - Some(selection_spanning_units(middle_line, first_line)), - size, - 0 - ), - vec![ - TerminalSelectionRange { - row: 0, - column: 0, - width: 5, - }, - TerminalSelectionRange { - row: 1, - column: 0, - width: 5, - }, - ] - ); - } - - #[test] - fn treats_empty_drag_area_as_line_unit_for_word_selection() { - let size = TerminalSize { - columns: 8, - rows: 2, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let anchor_word = TerminalSelection::range( - TerminalSelectionPoint { row: 0, column: 1 }, - TerminalSelectionPoint { row: 0, column: 3 }, - ); - let empty_row = test_terminal_row(" "); - let active_line = line_selection_for_empty_area( - &empty_row, - TerminalSelectionPoint { row: 1, column: 4 }, - size, - ) - .expect("blank rows should select as a line while word dragging"); - - assert_eq!( - selection_ranges( - Some(selection_spanning_units(anchor_word, active_line)), - size, - 0 - ), - vec![ - TerminalSelectionRange { - row: 0, - column: 1, - width: 7, - }, - TerminalSelectionRange { - row: 1, - column: 0, - width: 8, - }, - ] - ); - } - - #[test] - fn detects_empty_drag_area_only_after_remaining_text() { - let size = TerminalSize { - columns: 8, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let row = test_terminal_row("foo bar "); - - assert_eq!( - line_selection_for_empty_area(&row, TerminalSelectionPoint { row: 0, column: 3 }, size), - None - ); - assert!( - line_selection_for_empty_area(&row, TerminalSelectionPoint { row: 0, column: 7 }, size) - .is_some() - ); - } - - #[test] - fn finds_terminal_word_columns_for_path_like_tokens() { - let row = test_terminal_row(" ./foo-bar baz "); - - assert_eq!(word_columns_for_row(&row, 5), Some(2..11)); - assert_eq!(word_columns_for_row(&row, 12), Some(12..15)); - assert_eq!(word_columns_for_row(&row, 1), None); - } - - #[test] - fn snapshot_keeps_wide_and_combining_text() { - let size = TerminalSize { - columns: 8, - rows: 2, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let mut term = Term::new(Config::default(), &size, VoidListener); - let mut parser: Processor = Processor::new(); - parser.advance(&mut term, "中e\u{301}".as_bytes()); - - let content = term.renderable_content(); - let rows = snapshot_rows( - content, - size, - TerminalTheme::for_palette(TerminalPalette::Dark), - ); - assert!(rows[0].text.contains('中')); - assert!(rows[0].text.contains("e\u{301}")); - } - - #[test] - fn snapshot_uses_theme_defaults() { - let size = TerminalSize { - columns: 2, - rows: 1, - cell_width: DEFAULT_CELL_WIDTH_PX, - cell_height: DEFAULT_CELL_HEIGHT_PX, - }; - let theme = TerminalTheme::new( - TerminalPalette::Light, - TerminalColor::rgb(0x1a1a1a), - TerminalColor::rgb(0xffffff), - ); - let term = Term::new(Config::default(), &size, VoidListener); - let content = term.renderable_content(); - let cursor_color = snapshot_cursor_color(content.colors, theme); - let rows = snapshot_rows(content, size, theme); - let style = rows[0].cells[0].style; - - assert_eq!(style.foreground, theme.foreground); - assert_eq!(style.background, theme.background); - assert_eq!(cursor_color, theme.foreground); - } - - fn test_terminal_row(text: &str) -> TerminalRow { - let style = TerminalStyle { - foreground: TerminalColor::rgb(0xffffff), - background: TerminalColor::rgb(0x000000), - bold: false, - italic: false, - underline: false, - dim: false, - strikeout: false, - }; - let cells = text - .chars() - .enumerate() - .map(|(column, ch)| TerminalCell { - text: ch.to_string(), - style, - column, - width: 1, - }) - .collect(); - - TerminalRow { - text: text.to_string(), - runs: Vec::new(), - cell_runs: Vec::new(), - cells, - } - } -} diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml deleted file mode 100644 index ffa7f6f3..00000000 --- a/crates/theme/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "theme" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui-component = { workspace = true } -gpui = { workspace = true } -serde_json = "1" diff --git a/crates/theme/README.md b/crates/theme/README.md deleted file mode 100644 index a3daa29c..00000000 --- a/crates/theme/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# theme - -Role: visual theme definitions. - -Owns: -- Theme colors and shared palette data. -- Syntax highlight style mapping from `HighlightId` to GPUI styles. -- Active theme global access. -- Theme setting id and built-in theme options. - -Does Not Own: -- Rendering components. -- Settings storage. -- Icon assets. diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs deleted file mode 100644 index 44614c9f..00000000 --- a/crates/theme/src/lib.rs +++ /dev/null @@ -1,471 +0,0 @@ -use std::sync::Arc; - -use gpui::{Anchor, App, Global, Hsla, Rgba, rgb}; -use gpui_component::Colorize; - -pub const SETTING_ID: &str = "appearance.theme"; -pub const DARK_ID: &str = "dark"; -pub const NEUTRAL_ID: &str = "neutral"; -pub const LIGHT_ID: &str = "light"; -pub const DEFAULT_ID: &str = DARK_ID; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Theme { - pub id: &'static str, - pub is_dark: bool, - - pub bg_root: Rgba, - pub bg_surface: Rgba, - pub bg_elevated: Rgba, - pub bg_hover: Rgba, - pub bg_selected: Rgba, - pub bg_hover_strong: Rgba, - pub bg_close_hover: Rgba, - - pub border: Rgba, - pub border_subtle: Rgba, - pub border_strong: Rgba, - - pub text: Rgba, - pub text_muted: Rgba, - pub text_subtle: Rgba, - pub text_emphasis: Rgba, - pub text_header: Rgba, - - pub accent: Rgba, - pub accent_secondary: Rgba, - pub danger: Rgba, - pub success: Rgba, - pub warning: Rgba, - - pub dirty: Rgba, - pub terminal_foreground: Rgba, - pub terminal_background: Rgba, - pub terminal_separator: Rgba, -} - -impl Theme { - pub fn dark() -> Self { - Self { - id: DARK_ID, - is_dark: true, - bg_root: rgb(0x000000), - bg_surface: rgb(0x000000), - bg_elevated: rgb(0x000000), - bg_hover: rgb(0x171717), - bg_selected: rgb(0x202020), - bg_hover_strong: rgb(0x282828), - bg_close_hover: rgb(0x303030), - - border: rgb(0x1a1a1a), - border_subtle: rgb(0x141414), - border_strong: rgb(0x292929), - - text: rgb(0xe5e5e5), - text_muted: rgb(0xb8b8b8), - text_subtle: rgb(0x8a8a8a), - text_emphasis: rgb(0xffffff), - text_header: rgb(0xd4d4d4), - - accent: rgb(0x3b82f6), - accent_secondary: rgb(0xa855f7), - danger: rgb(0xdc2626), - success: rgb(0x22c55e), - warning: rgb(0xf59e0b), - - dirty: rgb(0xffffff), - terminal_foreground: rgb(0xe5e5e5), - terminal_background: rgb(0x000000), - terminal_separator: rgb(0x242424), - } - } - - pub fn light() -> Self { - Self { - id: LIGHT_ID, - is_dark: false, - bg_root: rgb(0xf5f5f5), - bg_surface: rgb(0xffffff), - bg_elevated: rgb(0xfafafa), - bg_hover: rgb(0xeaeaea), - bg_selected: rgb(0xdcdcdc), - bg_hover_strong: rgb(0xcfcfcf), - bg_close_hover: rgb(0xc2c2c2), - - border: rgb(0xd9d9d9), - border_subtle: rgb(0xe4e4e4), - border_strong: rgb(0xbfbfbf), - - text: rgb(0x1a1a1a), - text_muted: rgb(0x4a4a4a), - text_subtle: rgb(0x6b6b6b), - text_emphasis: rgb(0x000000), - text_header: rgb(0x2a2a2a), - - accent: rgb(0x2563eb), - accent_secondary: rgb(0x9333ea), - danger: rgb(0xdc2626), - success: rgb(0x16a34a), - warning: rgb(0xd97706), - - dirty: rgb(0x111827), - terminal_foreground: rgb(0x1a1a1a), - terminal_background: rgb(0xffffff), - terminal_separator: rgb(0xbfbfbf), - } - } - - pub fn neutral() -> Self { - Self { - id: NEUTRAL_ID, - is_dark: true, - bg_root: rgb(0x262626), - bg_surface: rgb(0x262626), - bg_elevated: rgb(0x262626), - bg_hover: rgb(0x303030), - bg_selected: rgb(0x383838), - bg_hover_strong: rgb(0x424242), - bg_close_hover: rgb(0x4a4a4a), - - border: rgb(0x3a3a3a), - border_subtle: rgb(0x303030), - border_strong: rgb(0x4a4a4a), - - text: rgb(0xe5e5e5), - text_muted: rgb(0xb8b8b8), - text_subtle: rgb(0x8f8f8f), - text_emphasis: rgb(0xffffff), - text_header: rgb(0xd4d4d4), - - accent: rgb(0x3b82f6), - accent_secondary: rgb(0xa855f7), - danger: rgb(0xdc2626), - success: rgb(0x22c55e), - warning: rgb(0xf59e0b), - - dirty: rgb(0xffffff), - terminal_foreground: rgb(0xe5e5e5), - terminal_background: rgb(0x262626), - terminal_separator: rgb(0x404040), - } - } - - pub fn by_id(id: &str) -> Self { - match Self::normalize_id(id) { - LIGHT_ID => Self::light(), - NEUTRAL_ID => Self::neutral(), - _ => Self::dark(), - } - } - - pub fn normalize_id(id: &str) -> &'static str { - match id { - LIGHT_ID => LIGHT_ID, - NEUTRAL_ID => NEUTRAL_ID, - DARK_ID => DARK_ID, - _ => DEFAULT_ID, - } - } - - fn syntax_palette(self) -> &'static str { - match self.id { - LIGHT_ID => LIGHT_SYNTAX_PALETTE, - NEUTRAL_ID => NEUTRAL_SYNTAX_PALETTE, - _ => DARK_SYNTAX_PALETTE, - } - } -} - -impl Global for Theme {} - -pub fn install(id: &str, cx: &mut App) { - set_active_theme(Theme::by_id(id), cx); -} - -pub fn apply(id: &str, cx: &mut App) -> bool { - let next = Theme::by_id(id); - if cx.has_global::() && *cx.global::() == next { - return false; - } - - set_active_theme(next, cx); - cx.refresh_windows(); - true -} - -fn set_active_theme(theme: Theme, cx: &mut App) { - cx.set_global(theme); - sync_component_theme(theme, cx); -} - -fn sync_component_theme(theme: Theme, cx: &mut App) { - let mode = if theme.is_dark { - gpui_component::ThemeMode::Dark - } else { - gpui_component::ThemeMode::Light - }; - gpui_component::Theme::change(mode, None, cx); - - let component_theme = gpui_component::Theme::global_mut(cx); - component_theme.notification.placement = Anchor::BottomRight; - - component_theme.colors.background = hsla(theme.bg_surface); - component_theme.colors.foreground = hsla(theme.text); - component_theme.colors.border = hsla(theme.border); - component_theme.colors.input = if theme.is_dark { - hsla(theme.bg_close_hover).lighten(0.04) - } else { - hsla(theme.border_strong) - }; - component_theme.colors.ring = hsla(theme.accent); - - component_theme.colors.muted = hsla(theme.bg_elevated); - component_theme.colors.muted_foreground = hsla(theme.text_subtle); - component_theme.colors.secondary = hsla(theme.bg_hover); - component_theme.colors.secondary_foreground = hsla(theme.text); - component_theme.colors.secondary_hover = hsla(theme.bg_selected); - component_theme.colors.secondary_active = hsla(theme.bg_hover_strong); - - component_theme.colors.primary = hsla(theme.accent); - component_theme.colors.primary_foreground = hsla(rgb(0xffffff)); - component_theme.colors.primary_hover = hsla(theme.accent); - component_theme.colors.primary_active = hsla(theme.accent); - component_theme.colors.button_primary = hsla(theme.accent); - component_theme.colors.button_primary_foreground = hsla(rgb(0xffffff)); - component_theme.colors.button_primary_hover = hsla(theme.accent); - component_theme.colors.button_primary_active = hsla(theme.accent); - - component_theme.colors.accent = hsla(theme.bg_hover); - component_theme.colors.accent_foreground = hsla(theme.text_emphasis); - component_theme.colors.selection = - hsla(theme.accent).opacity(if theme.is_dark { 0.28 } else { 0.25 }); - - component_theme.colors.danger = hsla(theme.danger); - component_theme.colors.danger_foreground = hsla(rgb(0xffffff)); - component_theme.colors.danger_hover = hsla(theme.danger); - component_theme.colors.danger_active = hsla(theme.danger); - component_theme.colors.success = hsla(theme.success); - component_theme.colors.success_foreground = hsla(rgb(0xffffff)); - component_theme.colors.success_hover = hsla(theme.success); - component_theme.colors.success_active = hsla(theme.success); - component_theme.colors.warning = hsla(theme.warning); - component_theme.colors.warning_foreground = hsla(rgb(0xffffff)); - component_theme.colors.warning_hover = hsla(theme.warning); - component_theme.colors.warning_active = hsla(theme.warning); - - component_theme.colors.link = hsla(theme.accent); - component_theme.colors.link_active = hsla(theme.accent); - component_theme.colors.link_hover = hsla(theme.accent); - - component_theme.colors.list = hsla(theme.bg_surface); - component_theme.colors.list_head = hsla(theme.bg_elevated); - component_theme.colors.list_even = hsla(theme.bg_surface); - component_theme.colors.list_hover = hsla(theme.bg_hover); - component_theme.colors.list_active = - hsla(theme.accent).opacity(if theme.is_dark { 0.18 } else { 0.16 }); - component_theme.colors.list_active_border = - hsla(theme.accent).opacity(if theme.is_dark { 0.42 } else { 0.55 }); - component_theme.colors.table = hsla(theme.bg_surface); - component_theme.colors.table_head = hsla(theme.bg_elevated); - component_theme.colors.table_head_foreground = hsla(theme.text_subtle); - component_theme.colors.table_even = hsla(theme.bg_surface); - component_theme.colors.table_hover = hsla(theme.bg_hover); - component_theme.colors.table_active = component_theme.colors.list_active; - component_theme.colors.table_active_border = component_theme.colors.list_active_border; - component_theme.colors.table_row_border = hsla(theme.border_subtle); - - component_theme.colors.popover = hsla(theme.bg_elevated); - component_theme.colors.popover_foreground = hsla(theme.text); - component_theme.colors.sidebar = hsla(theme.bg_surface); - component_theme.colors.sidebar_foreground = hsla(theme.text); - component_theme.colors.sidebar_border = hsla(theme.border); - component_theme.colors.sidebar_accent = hsla(theme.bg_hover); - component_theme.colors.sidebar_accent_foreground = hsla(theme.text_emphasis); - component_theme.colors.sidebar_primary = hsla(theme.accent); - component_theme.colors.sidebar_primary_foreground = hsla(rgb(0xffffff)); - - component_theme.colors.tab = hsla(theme.bg_surface); - component_theme.colors.tab_active = hsla(theme.bg_elevated); - component_theme.colors.tab_foreground = hsla(theme.text_muted); - component_theme.colors.tab_active_foreground = hsla(theme.text_emphasis); - component_theme.colors.tab_bar = hsla(theme.bg_root); - component_theme.colors.tab_bar_segmented = hsla(theme.bg_surface); - - component_theme.colors.scrollbar = hsla(theme.bg_surface); - component_theme.colors.scrollbar_thumb = hsla(theme.border_strong); - component_theme.colors.scrollbar_thumb_hover = hsla(theme.text_subtle); - component_theme.colors.switch = hsla(theme.bg_selected); - component_theme.colors.switch_thumb = hsla(theme.bg_surface); - component_theme.colors.skeleton = hsla(theme.bg_hover); - component_theme.colors.overlay = - hsla(rgb(0x000000)).opacity(if theme.is_dark { 0.55 } else { 0.28 }); - component_theme.colors.window_border = hsla(theme.border); - component_theme.colors.title_bar = hsla(theme.bg_surface); - component_theme.colors.title_bar_border = hsla(theme.border); - - let mut highlight_theme = (*component_theme.highlight_theme).clone(); - highlight_theme.style.editor_background = Some(gpui::transparent_black()); - highlight_theme.style.syntax = syntax_colors(theme); - component_theme.highlight_theme = Arc::new(highlight_theme); -} - -fn syntax_colors(theme: Theme) -> gpui_component::highlighter::SyntaxColors { - serde_json::from_str(theme.syntax_palette()).expect("Kosmos syntax palette should be valid") -} - -fn hsla(color: Rgba) -> Hsla { - Hsla::from(color) -} - -const DARK_SYNTAX_PALETTE: &str = r##" -{ - "attribute": { "color": "#D19A66" }, - "boolean": { "color": "#D19A66" }, - "comment": { "color": "#7F848E", "font_style": "italic" }, - "comment.doc": { "color": "#7F848E", "font_style": "italic" }, - "constant": { "color": "#D19A66" }, - "constructor": { "color": "#E5C07B" }, - "embedded": { "color": "#ABB2BF" }, - "emphasis": { "font_style": "italic" }, - "emphasis.strong": { "font_weight": 700 }, - "function": { "color": "#61AFEF" }, - "keyword": { "color": "#C678DD" }, - "link_text": { "color": "#61AFEF", "font_style": "normal" }, - "link_uri": { "color": "#7FAEF9", "font_style": "italic" }, - "number": { "color": "#D19A66" }, - "operator": { "color": "#56B6C2" }, - "punctuation": { "color": "#ABB2BF" }, - "punctuation.bracket": { "color": "#ABB2BF" }, - "punctuation.delimiter": { "color": "#ABB2BF" }, - "string": { "color": "#98C379" }, - "string.escape": { "color": "#56B6C2" }, - "string.regex": { "color": "#98C379" }, - "string.special": { "color": "#E06C75" }, - "string.special.symbol": { "color": "#E06C75" }, - "tag": { "color": "#E06C75" }, - "text.literal": { "color": "#98C379" }, - "text.code.span": { "color": "#98C379" }, - "title": { "color": "#61AFEF", "font_weight": 600 }, - "type": { "color": "#E5C07B" }, - "property": { "color": "#E06C75" }, - "variable": { "color": "#ABB2BF" }, - "variable.special": { "color": "#E06C75" } -} -"##; - -const NEUTRAL_SYNTAX_PALETTE: &str = r##" -{ - "attribute": { "color": "#E0A969" }, - "boolean": { "color": "#E0A969" }, - "comment": { "color": "#A3A3A3", "font_style": "italic" }, - "comment.doc": { "color": "#A3A3A3", "font_style": "italic" }, - "constant": { "color": "#E0A969" }, - "constructor": { "color": "#E6C36A" }, - "embedded": { "color": "#E5E5E5" }, - "emphasis": { "font_style": "italic" }, - "emphasis.strong": { "font_weight": 700 }, - "function": { "color": "#93C5FD" }, - "keyword": { "color": "#C084FC" }, - "link_text": { "color": "#93C5FD", "font_style": "normal" }, - "link_uri": { "color": "#A5B4FC", "font_style": "italic" }, - "number": { "color": "#E0A969" }, - "operator": { "color": "#67E8F9" }, - "punctuation": { "color": "#D4D4D4" }, - "punctuation.bracket": { "color": "#D4D4D4" }, - "punctuation.delimiter": { "color": "#D4D4D4" }, - "string": { "color": "#86EFAC" }, - "string.escape": { "color": "#67E8F9" }, - "string.regex": { "color": "#86EFAC" }, - "string.special": { "color": "#FCA5A5" }, - "string.special.symbol": { "color": "#FCA5A5" }, - "tag": { "color": "#FCA5A5" }, - "text.literal": { "color": "#86EFAC" }, - "text.code.span": { "color": "#86EFAC" }, - "title": { "color": "#93C5FD", "font_weight": 600 }, - "type": { "color": "#E6C36A" }, - "property": { "color": "#FCA5A5" }, - "variable": { "color": "#E5E5E5" }, - "variable.special": { "color": "#FCA5A5" } -} -"##; - -const LIGHT_SYNTAX_PALETTE: &str = r##" -{ - "attribute": { "color": "#B45309" }, - "boolean": { "color": "#B45309" }, - "comment": { "color": "#6B7280", "font_style": "italic" }, - "comment.doc": { "color": "#6B7280", "font_style": "italic" }, - "constant": { "color": "#B45309" }, - "constructor": { "color": "#7C3AED" }, - "embedded": { "color": "#1F2937" }, - "emphasis": { "font_style": "italic" }, - "emphasis.strong": { "font_weight": 700 }, - "function": { "color": "#2563EB" }, - "keyword": { "color": "#9333EA" }, - "link_text": { "color": "#2563EB", "font_style": "normal" }, - "link_uri": { "color": "#4F46E5", "font_style": "italic" }, - "number": { "color": "#B45309" }, - "operator": { "color": "#0F766E" }, - "punctuation": { "color": "#4B5563" }, - "punctuation.bracket": { "color": "#4B5563" }, - "punctuation.delimiter": { "color": "#4B5563" }, - "string": { "color": "#15803D" }, - "string.escape": { "color": "#0F766E" }, - "string.regex": { "color": "#15803D" }, - "string.special": { "color": "#DC2626" }, - "string.special.symbol": { "color": "#DC2626" }, - "tag": { "color": "#DC2626" }, - "text.literal": { "color": "#15803D" }, - "text.code.span": { "color": "#15803D" }, - "title": { "color": "#2563EB", "font_weight": 600 }, - "type": { "color": "#7C3AED" }, - "property": { "color": "#DC2626" }, - "variable": { "color": "#1F2937" }, - "variable.special": { "color": "#DC2626" } -} -"##; - -pub trait ActiveTheme { - fn theme(&self) -> &Theme; -} - -impl ActiveTheme for App { - fn theme(&self) -> &Theme { - self.global::() - } -} - -/// A selectable option for a string-valued setting (id is what gets persisted, -/// label is what the user sees). Lives here so foundational crates can declare -/// their own option lists without depending on `settings`. -pub struct DropdownOption { - pub id: &'static str, - pub label: &'static str, -} - -pub const REGISTRY: &[DropdownOption] = &[ - DropdownOption { - id: DARK_ID, - label: "Kosmos Dark", - }, - DropdownOption { - id: NEUTRAL_ID, - label: "Kosmos Neutral", - }, - DropdownOption { - id: LIGHT_ID, - label: "Kosmos Light", - }, -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn syntax_palettes_are_valid() { - for theme in [Theme::dark(), Theme::neutral(), Theme::light()] { - syntax_colors(theme); - } - } -} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml deleted file mode 100644 index 1ba544fd..00000000 --- a/crates/ui/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "ui" -version = "0.4.9" -edition = "2024" - -[dependencies] -anyhow = "1" -file_editor = { path = "../file_editor" } -file_search = { path = "../file_search" } -file_tree = { path = "../file_tree" } -gpui = { workspace = true } -gpui-component = { workspace = true } -icons = { path = "../icons" } -installer = { path = "../installer" } -kosmos_git = { package = "git", path = "../git" } -language = { path = "../language" } -lsp = { path = "../lsp" } -lsp-types = "0.97" -registry = { path = "../registry" } -settings = { path = "../settings" } -shortcuts = { path = "../shortcuts" } -theme = { path = "../theme" } -terminal = { path = "../terminal" } -workspace = { path = "../workspace" } -pane_tree = { path = "../pane_tree" } -panes = { path = "../panes" } -tabs = { path = "../tabs" } diff --git a/crates/ui/README.md b/crates/ui/README.md deleted file mode 100644 index 756a1cf1..00000000 --- a/crates/ui/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# ui - -Role: GPUI rendering and UI interaction boundary. - -Owns: -- Shared GPUI components. -- Application layout rendering. -- Tab body rendering. -- UI delegate traits implemented by the application shell. -- Drag payloads and drop-zone UI behavior. -- GPUI action wiring that belongs at the UI boundary. -- Tab icon resolution for presentation. - -Does Not Own: -- Application state orchestration. -- Domain mutation rules for panes, workspaces, file trees, or editors. -- SQLite persistence. -- External tool installation. diff --git a/crates/ui/src/delegate.rs b/crates/ui/src/delegate.rs deleted file mode 100644 index cf482dec..00000000 --- a/crates/ui/src/delegate.rs +++ /dev/null @@ -1,332 +0,0 @@ -use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::rc::Rc; - -use gpui::{App, Context, Global, ScrollHandle, Window}; -use pane_tree::DropZone; -use settings::SettingValue; - -use crate::drag::TabDrag; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TabAnimationPhase { - Opening, - Closing, -} - -impl TabAnimationPhase { - pub fn progress(self, delta: f32) -> f32 { - match self { - Self::Opening => delta, - Self::Closing => 1.0 - delta, - } - } - - pub fn key(self) -> &'static str { - match self { - Self::Opening => "opening", - Self::Closing => "closing", - } - } -} - -#[derive(Default)] -pub struct TabAnimationState { - opening: HashSet<(usize, usize, usize)>, - closing: HashSet<(usize, usize, usize)>, -} - -impl TabAnimationState { - pub fn phase( - &self, - workspace_id: usize, - pane_id: usize, - tab_id: usize, - ) -> Option { - let key = (workspace_id, pane_id, tab_id); - if self.closing.contains(&key) { - return Some(TabAnimationPhase::Closing); - } - if self.opening.contains(&key) { - return Some(TabAnimationPhase::Opening); - } - None - } - - pub fn start_opening(&mut self, workspace_id: usize, pane_id: usize, tab_id: usize) -> bool { - let key = (workspace_id, pane_id, tab_id); - self.closing.remove(&key); - self.opening.insert(key) - } - - pub fn finish_opening(&mut self, workspace_id: usize, pane_id: usize, tab_id: usize) -> bool { - self.opening.remove(&(workspace_id, pane_id, tab_id)) - } - - pub fn start_closing(&mut self, workspace_id: usize, pane_id: usize, tab_id: usize) -> bool { - let key = (workspace_id, pane_id, tab_id); - self.opening.remove(&key); - self.closing.insert(key) - } - - pub fn finish_closing(&mut self, workspace_id: usize, pane_id: usize, tab_id: usize) -> bool { - self.closing.remove(&(workspace_id, pane_id, tab_id)) - } -} - -impl Global for TabAnimationState {} - -pub trait WorkspaceDelegate: Sized + 'static { - fn open_workspace_picker(&mut self, cx: &mut Context); - fn select_workspace(&mut self, id: usize, cx: &mut Context); - fn move_workspace_before(&mut self, drag_id: usize, target_id: usize, cx: &mut Context); - fn move_workspace_to_end(&mut self, drag_id: usize, cx: &mut Context); - fn close_workspace(&mut self, id: usize, cx: &mut Context); -} - -pub trait HeaderDelegate: WorkspaceDelegate { - fn activate_header_menu_action( - &mut self, - action: HeaderMenuAction, - window: &mut Window, - cx: &mut Context, - ); -} - -pub trait PaneDelegate: Sized + 'static { - fn focus_pane(&mut self, pane_id: usize, cx: &mut Context); - fn add_tab(&mut self, pane_id: usize, kind_id: &'static str, cx: &mut Context); - fn replace_tab_kind( - &mut self, - pane_id: usize, - tab_id: usize, - kind_id: &'static str, - cx: &mut Context, - ); - fn select_tab(&mut self, pane_id: usize, tab_id: usize, cx: &mut Context); - fn close_tab(&mut self, pane_id: usize, tab_id: usize, cx: &mut Context); - fn move_tab_before( - &mut self, - drag: TabDrag, - target_pane_id: usize, - target_tab_id: usize, - cx: &mut Context, - ); - fn move_tab_to_pane(&mut self, drag: TabDrag, target_pane_id: usize, cx: &mut Context); - fn move_tab_to_end(&mut self, drag: TabDrag, target_pane_id: usize, cx: &mut Context); - fn split_pane( - &mut self, - drag: TabDrag, - target_pane_id: usize, - drop_zone: DropZone, - cx: &mut Context, - ); - fn resize_split(&mut self, split_id: usize, ratio: f32, cx: &mut Context); - fn finish_resize_split(&mut self, _cx: &mut Context) {} - fn open_file(&mut self, path: PathBuf, cx: &mut Context); - fn open_file_in_pane(&mut self, path: PathBuf, target_pane_id: usize, cx: &mut Context); - fn open_file_before( - &mut self, - path: PathBuf, - target_pane_id: usize, - target_tab_id: usize, - cx: &mut Context, - ); - fn open_file_at(&mut self, path: PathBuf, line: usize, column: usize, cx: &mut Context); - fn open_diff_for_file(&mut self, root: PathBuf, file_path: String, cx: &mut Context); - fn split_pane_with_file( - &mut self, - path: PathBuf, - target_pane_id: usize, - drop_zone: DropZone, - cx: &mut Context, - ); -} - -pub trait SettingsDelegate: Sized + 'static { - fn select_settings_category(&mut self, category_id: &'static str, cx: &mut Context); - fn toggle_settings_dropdown(&mut self, setting_id: &'static str, cx: &mut Context); - fn set_setting_value(&mut self, key: &'static str, value: SettingValue, cx: &mut Context); - fn install_tool(&mut self, entry: &'static registry::RegistryEntry, cx: &mut Context); - fn uninstall_tool(&mut self, entry: &'static registry::RegistryEntry, cx: &mut Context); -} - -#[derive(Clone, Debug)] -pub struct SettingsUiState { - pub active_category: &'static str, - pub open_dropdown: Option<&'static str>, - pub installing: std::collections::HashSet<&'static str>, - pub install_errors: std::collections::HashMap<&'static str, gpui::SharedString>, -} - -impl SettingsUiState { - pub fn new() -> Self { - let active_category = settings::Settings::categories() - .first() - .map(|c| c.id) - .unwrap_or(""); - Self { - active_category, - open_dropdown: None, - installing: Default::default(), - install_errors: Default::default(), - } - } -} - -impl Default for SettingsUiState { - fn default() -> Self { - Self::new() - } -} - -impl Global for SettingsUiState {} - -pub trait ActiveSettingsUi { - fn settings_ui(&self) -> &SettingsUiState; -} - -impl ActiveSettingsUi for App { - fn settings_ui(&self) -> &SettingsUiState { - self.global::() - } -} - -#[derive(Clone, Default)] -pub struct TabScrollHandles { - handles: Rc>>, - end_anchor_counts: Rc>>, -} - -impl TabScrollHandles { - pub fn new() -> Self { - Self::default() - } - - pub fn handle(&self, pane_id: usize) -> ScrollHandle { - self.handles - .borrow_mut() - .entry(pane_id) - .or_default() - .clone() - } - - pub fn scroll_to_index(&self, pane_id: usize, index: usize) { - if let Some(handle) = self.handles.borrow().get(&pane_id) { - handle.scroll_to_item(index); - } - } - - pub fn start_end_anchor(&self, pane_id: usize) { - *self - .end_anchor_counts - .borrow_mut() - .entry(pane_id) - .or_default() += 1; - } - - pub fn finish_end_anchor(&self, pane_id: usize) { - let mut anchors = self.end_anchor_counts.borrow_mut(); - let Some(count) = anchors.get_mut(&pane_id) else { - return; - }; - *count = (*count).saturating_sub(1); - if *count == 0 { - anchors.remove(&pane_id); - } - } - - pub fn is_end_anchored(&self, pane_id: usize) -> bool { - self.end_anchor_counts.borrow().contains_key(&pane_id) - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum HeaderMenu { - File, - Edit, - Selection, -} - -impl HeaderMenu { - pub fn id(self) -> usize { - match self { - Self::File => 0, - Self::Edit => 1, - Self::Selection => 2, - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum HeaderMenuAction { - OpenFolder, - Save, - SaveAll, - Undo, - Redo, - Cut, - Copy, - Paste, - SelectAll, - ExpandSelection, - ShrinkSelection, -} - -impl HeaderMenuAction { - pub fn shortcut_action_name(self) -> Option<&'static str> { - match self { - Self::Save => Some("file_editor::Save"), - Self::Undo => Some("input::Undo"), - Self::Redo => Some("input::Redo"), - Self::Cut => Some("input::Cut"), - Self::Copy => Some("input::Copy"), - Self::Paste => Some("input::Paste"), - Self::SelectAll => Some("input::SelectAll"), - Self::OpenFolder | Self::SaveAll | Self::ExpandSelection | Self::ShrinkSelection => { - None - } - } - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct HeaderMenuAvailability { - pub active_editor: bool, - pub active_editor_dirty: bool, - pub any_dirty_file: bool, - pub can_undo: bool, - pub can_redo: bool, - pub can_cut: bool, - pub can_copy: bool, - pub can_paste: bool, - pub can_select_all: bool, - pub can_expand_selection: bool, - pub can_shrink_selection: bool, -} - -impl HeaderMenuAvailability { - pub fn menu_enabled(self, menu: HeaderMenu) -> bool { - match menu { - HeaderMenu::File => true, - HeaderMenu::Edit | HeaderMenu::Selection => self.active_editor, - } - } - - pub fn action_enabled(self, action: HeaderMenuAction) -> bool { - match action { - HeaderMenuAction::OpenFolder => true, - HeaderMenuAction::Save => self.active_editor_dirty, - HeaderMenuAction::SaveAll => self.any_dirty_file, - HeaderMenuAction::Undo => self.can_undo, - HeaderMenuAction::Redo => self.can_redo, - HeaderMenuAction::Cut => self.can_cut, - HeaderMenuAction::Copy => self.can_copy, - HeaderMenuAction::Paste => self.can_paste, - HeaderMenuAction::SelectAll => self.can_select_all, - HeaderMenuAction::ExpandSelection => self.can_expand_selection, - HeaderMenuAction::ShrinkSelection => self.can_shrink_selection, - } - } -} diff --git a/crates/ui/src/drag.rs b/crates/ui/src/drag.rs deleted file mode 100644 index aba0b1b5..00000000 --- a/crates/ui/src/drag.rs +++ /dev/null @@ -1,137 +0,0 @@ -use gpui::{ - Context, IntoElement, Pixels, Point, Render, SharedString, Window, div, prelude::*, rems, -}; - -use icons::{Icon, IconName}; -use pane_tree::SplitAxis; -use theme::ActiveTheme; - -use crate::metrics::{TAB_HEIGHT, TAB_RADIUS}; - -#[derive(Clone)] -pub struct TabDrag { - pub id: usize, - pub source_pane_id: usize, - pub title: SharedString, - pub icon: IconName, - position: Point, -} - -impl TabDrag { - pub fn new(id: usize, source_pane_id: usize, title: SharedString, icon: IconName) -> Self { - Self { - id, - source_pane_id, - title, - icon, - position: Point::default(), - } - } - - pub fn position(mut self, position: Point) -> Self { - self.position = position; - self - } -} - -impl Render for TabDrag { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = *cx.theme(); - let rem = window.rem_size(); - div() - .pl(self.position.x - rems(4.375).to_pixels(rem)) - .pt(self.position.y - rems(1.125).to_pixels(rem)) - .child( - div() - .h(TAB_HEIGHT) - .flex() - .flex_none() - .items_center() - .gap_2() - .px_3() - .rounded(TAB_RADIUS) - .bg(theme.bg_selected) - .text_sm() - .text_color(theme.text_emphasis) - .shadow_lg() - .child( - Icon::new(self.icon) - .size_rem(1.0) - .color(theme.text) - .into_any_element(), - ) - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(self.title.clone()), - ), - ) - } -} - -#[derive(Clone)] -pub struct WorkspaceDrag { - pub id: usize, - pub initial: SharedString, - position: Point, -} - -impl WorkspaceDrag { - pub fn new(id: usize, initial: SharedString) -> Self { - Self { - id, - initial, - position: Point::default(), - } - } - - pub fn position(mut self, position: Point) -> Self { - self.position = position; - self - } -} - -impl Render for WorkspaceDrag { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = *cx.theme(); - let rem = window.rem_size(); - div() - .pl(self.position.x - rems(0.875).to_pixels(rem)) - .pt(self.position.y - rems(0.875).to_pixels(rem)) - .child( - div() - .size(rems(1.75)) - .flex() - .flex_none() - .items_center() - .justify_center() - .rounded(rems(0.3125)) - .text_sm() - .bg(theme.bg_selected) - .text_color(theme.text_emphasis) - .shadow_lg() - .child(self.initial.clone()), - ) - } -} - -#[derive(Clone, Copy)] -pub struct SplitResize { - pub split_id: usize, - pub axis: SplitAxis, -} - -impl SplitResize { - pub fn new(split_id: usize, axis: SplitAxis) -> Self { - Self { split_id, axis } - } -} - -impl Render for SplitResize { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - } -} diff --git a/crates/ui/src/layout/header.rs b/crates/ui/src/layout/header.rs deleted file mode 100644 index 3beb8ad1..00000000 --- a/crates/ui/src/layout/header.rs +++ /dev/null @@ -1,3 +0,0 @@ -include!("header/main.rs"); -include!("header/workspace_bar.rs"); -include!("header/menus_and_window_buttons.rs"); diff --git a/crates/ui/src/layout/header/main.rs b/crates/ui/src/layout/header/main.rs deleted file mode 100644 index 907e54a5..00000000 --- a/crates/ui/src/layout/header/main.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::{rc::Rc, time::Duration}; - -use gpui::{ - Animation, AnimationExt, AnyElement, App, ClickEvent, Context, IntoElement, Keystroke, - MouseButton, SharedString, Window, WindowControlArea, div, ease_in_out, prelude::*, rems, svg, -}; -use gpui_component::{ - Disableable, Icon as ComponentIcon, Sizable, - button::{Button, ButtonVariants}, - kbd::Kbd, - menu::{ContextMenuExt, DropdownMenu, PopupMenuItem}, -}; - -use icons::{Icon, IconName}; -use theme::{ActiveTheme, Theme}; -use workspace::{Workspace, WorkspaceManager}; - -use crate::delegate::{ - HeaderDelegate, HeaderMenu, HeaderMenuAction, HeaderMenuAvailability, WorkspaceDelegate, -}; -use crate::drag::WorkspaceDrag; - -pub fn render( - workspace_manager: &WorkspaceManager, - menu_availability: HeaderMenuAvailability, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .id("app-header") - .h(rems(2.5)) - .w_full() - .flex() - .items_center() - .justify_between() - .bg(theme.bg_surface) - .rounded(rems(0.5)) - .border_1() - .border_color(theme.border) - .text_color(theme.text_header) - .on_mouse_down(MouseButton::Left, |event, window, cx| { - cx.stop_propagation(); - if event.click_count >= 2 { - window.zoom_window(); - } else { - window.start_window_move(); - } - }) - .child( - div() - .flex() - .items_center() - .gap_1() - .px_1() - .child( - div() - .flex() - .items_center() - .justify_center() - .h(rems(1.75)) - .px(rems(0.5)) - .child( - svg() - .path("brand/kosmos-icon.svg") - .h(rems(1.125)) - .w(rems(1.35)) - .flex_none() - .text_color(theme.text_emphasis), - ), - ) - .child(render_menu_button::( - HeaderMenu::File, - "File", - menu_availability, - cx, - )) - .child(render_menu_button::( - HeaderMenu::Edit, - "Edit", - menu_availability, - cx, - )) - .child(render_menu_button::( - HeaderMenu::Selection, - "Selection", - menu_availability, - cx, - )), - ) - .child( - div() - .flex_1() - .h_full() - .window_control_area(WindowControlArea::Drag), - ) - .child(render_workspace_bar(workspace_manager, window, cx)) - .child( - div() - .flex_1() - .h_full() - .window_control_area(WindowControlArea::Drag), - ) - .child( - div() - .flex() - .items_center() - .h_full() - .child(render_window_button( - "window-minimize", - IconName::ChromeMinimize, - theme.bg_hover_strong, - WindowControlArea::Min, - false, - |window| window.minimize_window(), - &theme, - )) - .child(render_window_button( - "window-maximize", - IconName::ChromeMaximize, - theme.bg_hover_strong, - WindowControlArea::Max, - false, - |window| window.zoom_window(), - &theme, - )) - .child(render_window_button( - "window-close", - IconName::ChromeClose, - theme.danger, - WindowControlArea::Close, - true, - |window| window.remove_window(), - &theme, - )), - ) - .into_any_element() -} diff --git a/crates/ui/src/layout/header/menus_and_window_buttons.rs b/crates/ui/src/layout/header/menus_and_window_buttons.rs deleted file mode 100644 index a3397ca2..00000000 --- a/crates/ui/src/layout/header/menus_and_window_buttons.rs +++ /dev/null @@ -1,182 +0,0 @@ -#[derive(Clone, Copy)] -struct HeaderMenuItem { - action: HeaderMenuAction, - label: &'static str, -} - -#[derive(Clone)] -struct HeaderPopupMenuItem { - label: &'static str, - shortcut: Option, - is_enabled: bool, - listener: HeaderMenuHandler, -} - -type HeaderMenuHandler = Rc; - -impl HeaderMenuItem { - const fn new(action: HeaderMenuAction, label: &'static str) -> Self { - Self { action, label } - } -} - -const FILE_MENU_ITEMS: &[HeaderMenuItem] = &[ - HeaderMenuItem::new(HeaderMenuAction::OpenFolder, "Open Folder..."), - HeaderMenuItem::new(HeaderMenuAction::Save, "Save"), - HeaderMenuItem::new(HeaderMenuAction::SaveAll, "Save All"), -]; - -const EDIT_MENU_ITEMS: &[HeaderMenuItem] = &[ - HeaderMenuItem::new(HeaderMenuAction::Undo, "Undo"), - HeaderMenuItem::new(HeaderMenuAction::Redo, "Redo"), - HeaderMenuItem::new(HeaderMenuAction::Cut, "Cut"), - HeaderMenuItem::new(HeaderMenuAction::Copy, "Copy"), - HeaderMenuItem::new(HeaderMenuAction::Paste, "Paste"), -]; - -const SELECTION_MENU_ITEMS: &[HeaderMenuItem] = &[ - HeaderMenuItem::new(HeaderMenuAction::SelectAll, "Select All"), - HeaderMenuItem::new(HeaderMenuAction::ExpandSelection, "Expand Selection"), - HeaderMenuItem::new(HeaderMenuAction::ShrinkSelection, "Shrink Selection"), -]; - -fn render_menu_button( - menu: HeaderMenu, - label: &'static str, - availability: HeaderMenuAvailability, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let is_enabled = availability.menu_enabled(menu); - - let button = Button::new(("menu-button", menu.id())) - .ghost() - .tab_stop(false) - .disabled(!is_enabled) - .h(rems(1.75)) - .px_3() - .text_sm() - .text_color(if is_enabled { - theme.text_header - } else { - theme.text_muted - }) - .hover(move |this| this.bg(theme.bg_hover).text_color(theme.text_emphasis)) - .when(!is_enabled, |this| this.opacity(0.45)) - .child(label); - - if !is_enabled { - return button.into_any_element(); - } - - let items = menu_items(menu) - .iter() - .map(|item| { - let action = item.action; - let listener: HeaderMenuHandler = Rc::new(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.activate_header_menu_action(action, window, cx); - })); - - HeaderPopupMenuItem { - label: item.label, - shortcut: action - .shortcut_action_name() - .and_then(|action| shortcuts::primary_keystroke_for_action(action, cx)), - is_enabled: availability.action_enabled(action), - listener, - } - }) - .collect::>(); - - button - .dropdown_menu(move |popup_menu, window, _| { - let menu_width = rems(17.0).to_pixels(window.rem_size()); - items.iter().cloned().fold( - popup_menu.min_w(menu_width).max_w(menu_width), - |popup_menu, item| popup_menu.item(render_menu_item(item)), - ) - }) - .into_any_element() -} - -fn render_menu_item(item: HeaderPopupMenuItem) -> PopupMenuItem { - let HeaderPopupMenuItem { - label, - shortcut, - is_enabled, - listener, - } = item; - - PopupMenuItem::element(move |_, cx| { - let theme = *cx.theme(); - let text_color = if is_enabled { - theme.text - } else { - theme.text_subtle - }; - let shortcut_color = if is_enabled { - theme.text_muted - } else { - theme.text_subtle - }; - - div() - .w_full() - .flex() - .items_center() - .justify_between() - .gap_4() - .text_color(text_color) - .child(div().flex_1().min_w_0().child(label)) - .when_some(shortcut.clone(), |this, shortcut| { - this.child( - div() - .flex_none() - .text_color(shortcut_color) - .child(Kbd::new(shortcut)), - ) - }) - }) - .disabled(!is_enabled) - .on_click(move |event, window, cx| listener(event, window, cx)) -} - -fn menu_items(menu: HeaderMenu) -> &'static [HeaderMenuItem] { - match menu { - HeaderMenu::File => FILE_MENU_ITEMS, - HeaderMenu::Edit => EDIT_MENU_ITEMS, - HeaderMenu::Selection => SELECTION_MENU_ITEMS, - } -} - -fn render_window_button( - id: &'static str, - icon: IconName, - hover_background: gpui::Rgba, - control_area: WindowControlArea, - round_right: bool, - action: impl Fn(&mut Window) + 'static, - theme: &Theme, -) -> impl IntoElement + 'static { - let text_color = theme.text_muted; - let hover_text = theme.text_emphasis; - div() - .id(id) - .h_full() - .w(rems(2.875)) - .flex() - .items_center() - .justify_center() - .text_sm() - .text_color(text_color) - .when(round_right, |this| this.rounded_r(rems(0.4375))) - .window_control_area(control_area) - .hover(move |this| this.bg(hover_background).text_color(hover_text)) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - action(window); - }) - .child(Icon::new(icon).size(16.0).color(text_color)) -} diff --git a/crates/ui/src/layout/header/workspace_bar.rs b/crates/ui/src/layout/header/workspace_bar.rs deleted file mode 100644 index d1e8e38c..00000000 --- a/crates/ui/src/layout/header/workspace_bar.rs +++ /dev/null @@ -1,240 +0,0 @@ -fn render_workspace_bar( - manager: &WorkspaceManager, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let active = manager.active_id(); - let previous_active = manager.previous_active_id(); - let active_changed = active != previous_active; - let mut elements: Vec = Vec::new(); - for workspace in manager.workspaces() { - let is_active = active == Some(workspace.id); - let should_animate = active_changed - && (Some(workspace.id) == active || Some(workspace.id) == previous_active); - elements.push(render_workspace_button( - workspace, - is_active, - should_animate, - window, - cx, - )); - } - elements.push(render_add_button(cx)); - - div() - .flex() - .items_center() - .gap_1() - .px_1() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .children(elements) - .into_any_element() -} - -fn render_add_button(cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - let hover_group = SharedString::from("workspace-add"); - let accent = theme.accent; - div() - .id("workspace-add") - .group(hover_group.clone()) - .relative() - .size(rems(1.75)) - .flex() - .items_center() - .justify_center() - .rounded(rems(0.3125)) - .text_color(theme.text_muted) - .hover(move |this| this.bg(theme.bg_hover).text_color(theme.text_emphasis)) - .can_drop(|drag, _, _| drag.downcast_ref::().is_some()) - .on_drop(cx.listener(|this, drag: &WorkspaceDrag, _, cx| { - cx.stop_propagation(); - this.move_workspace_to_end(drag.id, cx); - })) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(|this, _, _, cx| { - cx.stop_propagation(); - this.open_workspace_picker(cx); - })) - .child( - div() - .absolute() - .left(rems(-0.1875)) - .top(rems(0.25)) - .bottom(rems(0.25)) - .w(rems(0.125)) - .rounded_full() - .hover(|s| s) - .group_drag_over::(hover_group, move |s| s.bg(accent)), - ) - .child(Icon::new(IconName::Add).size(16.0).color(theme.text_muted)) - .into_any_element() -} - -fn render_workspace_button( - workspace: &Workspace, - is_active: bool, - should_animate: bool, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let id = workspace.id; - let initial = SharedString::from(workspace.initial()); - let name = SharedString::from(workspace.name.clone()); - let hover_group = SharedString::from(format!("workspace-{id}")); - let accent = theme.accent; - let drag_initial = initial.clone(); - let close_listener: HeaderMenuHandler = Rc::new(cx.listener(move |this, _, _, cx| { - this.close_workspace(id, cx); - })); - - let inactive_w = 1.75_f32; - let active_w = measure_text_rems(window, name.as_ref()) + 1.25; - let anim_id = SharedString::from(format!("ws-anim-{id}-{}", is_active as u8)); - - let content = div().relative().h_full().overflow_hidden(); - let content = if should_animate { - content - .with_animation( - anim_id, - Animation::new(Duration::from_millis(180)).with_easing(ease_in_out), - move |el, delta| { - let animation_progress = if is_active { delta } else { 1.0 - delta }; - let width_rem = inactive_w + (active_w - inactive_w) * animation_progress; - el.w(rems(width_rem)) - .child( - div() - .absolute() - .top(rems(0.)) - .left(rems(0.)) - .size_full() - .flex() - .items_center() - .justify_center() - .opacity(1.0 - animation_progress) - .child(initial.clone()), - ) - .child( - div() - .absolute() - .top(rems(0.)) - .left(rems(0.)) - .size_full() - .flex() - .items_center() - .justify_center() - .opacity(animation_progress) - .child(name.clone()), - ) - }, - ) - .into_any_element() - } else { - let width_rem = if is_active { active_w } else { inactive_w }; - content - .w(rems(width_rem)) - .flex() - .items_center() - .justify_center() - .child(if is_active { name } else { initial }) - .into_any_element() - }; - - div() - .id(("workspace", id)) - .group(hover_group.clone()) - .relative() - .h(rems(1.75)) - .flex() - .items_center() - .rounded(rems(0.3125)) - .text_sm() - .bg(if is_active { - theme.bg_selected - } else { - theme.bg_surface - }) - .text_color(if is_active { - theme.text_emphasis - } else { - theme.text_muted - }) - .hover(move |this| this.bg(theme.bg_hover).text_color(theme.text_emphasis)) - .can_drop(move |drag, _, _| { - drag.downcast_ref::() - .is_some_and(|drag| drag.id != id) - }) - .on_drop(cx.listener(move |this, drag: &WorkspaceDrag, _, cx| { - cx.stop_propagation(); - this.move_workspace_before(drag.id, id, cx); - })) - .on_drag( - WorkspaceDrag::new(id, drag_initial), - |drag, position, _, cx| cx.new(|_| drag.clone().position(position)), - ) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.select_workspace(id, cx); - })) - .child( - div() - .absolute() - .left(rems(-0.1875)) - .top(rems(0.25)) - .bottom(rems(0.25)) - .w(rems(0.125)) - .rounded_full() - .hover(|s| s) - .group_drag_over::(hover_group, move |s| s.bg(accent)), - ) - .child(content) - .context_menu(move |menu, window, _| { - let menu_width = rems(10.0).to_pixels(window.rem_size()); - menu.min_w(menu_width).item(workspace_menu_item( - "Close", - ComponentIcon::empty().path(IconName::Close.path()), - close_listener.clone(), - )) - }) - .into_any_element() -} - -fn workspace_menu_item( - label: &'static str, - icon: ComponentIcon, - listener: HeaderMenuHandler, -) -> PopupMenuItem { - PopupMenuItem::element(move |_, cx| { - let theme = *cx.theme(); - div() - .w_full() - .flex() - .items_center() - .gap_2() - .text_color(theme.text) - .child( - div() - .w(rems(1.0)) - .flex() - .items_center() - .justify_center() - .text_color(theme.text_muted) - .child(icon.clone().small()), - ) - .child(label) - }) - .on_click(move |event, window, cx| listener(event, window, cx)) -} - -fn measure_text_rems(window: &mut Window, text: &str) -> f32 { - let style = window.text_style(); - let run = style.to_run(text.len()); - let rem_size = window.rem_size(); - let font_size = rem_size * 0.875_f32; - let layout = window - .text_system() - .layout_line(text, font_size, &[run], None); - f32::from(layout.width) / f32::from(rem_size) -} diff --git a/crates/ui/src/layout/landing.rs b/crates/ui/src/layout/landing.rs deleted file mode 100644 index f5510ff1..00000000 --- a/crates/ui/src/layout/landing.rs +++ /dev/null @@ -1,44 +0,0 @@ -use gpui::{AnyElement, Context, IntoElement, div, prelude::*, rems}; -use gpui_component::{ - Icon as ComponentIcon, - button::{Button, ButtonVariants}, -}; - -use icons::IconName; -use theme::ActiveTheme; - -use crate::delegate::WorkspaceDelegate; - -pub fn render(cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - div() - .size_full() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_3() - .bg(theme.bg_surface) - .rounded(rems(0.5)) - .border_1() - .border_color(theme.border) - .text_color(theme.text) - .child(div().text_2xl().child("Welcome to Kosmos!")) - .child( - div() - .text_color(theme.text_subtle) - .child("Open your first workspace to get started"), - ) - .child( - Button::new("landing-open-workspace") - .secondary() - .icon(ComponentIcon::empty().path(IconName::Add.path())) - .label("Open Workspace") - .mt_2() - .on_click(cx.listener(|this, _, _, cx| { - cx.stop_propagation(); - this.open_workspace_picker(cx); - })), - ) - .into_any_element() -} diff --git a/crates/ui/src/layout/main_content/mod.rs b/crates/ui/src/layout/main_content/mod.rs deleted file mode 100644 index c1bef434..00000000 --- a/crates/ui/src/layout/main_content/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod pane; -mod pane_tree; -mod tab; - -use gpui::{AnyElement, Context, IntoElement, Window, div, prelude::*, rems}; -use theme::ActiveTheme; -use workspace::WorkspaceManager; - -use crate::delegate::{PaneDelegate, SettingsDelegate, TabScrollHandles, WorkspaceDelegate}; -use crate::layout::landing; - -pub fn render( - workspaces: &WorkspaceManager, - tab_scrolls: &TabScrollHandles, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - match workspaces.active_workspace() { - Some(workspace) => div() - .size_full() - .rounded(rems(0.5)) - .border_1() - .border_color(theme.border) - .overflow_hidden() - .child(pane_tree::render( - &workspace.pane_tree, - workspace.pane_tree.root(), - workspace.id, - &workspace.path, - tab_scrolls, - window, - cx, - )) - .into_any_element(), - None => landing::render(cx), - } -} diff --git a/crates/ui/src/layout/main_content/pane.rs b/crates/ui/src/layout/main_content/pane.rs deleted file mode 100644 index 51565b32..00000000 --- a/crates/ui/src/layout/main_content/pane.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::path::Path; - -use file_tree::NodeKind; -use gpui::{AnyElement, Context, IntoElement, Window, div, prelude::*, rems}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, - button::{Button, ButtonVariants}, - separator::Separator, -}; - -use icons::IconName; -use pane_tree::{DropZone, PaneTree}; -use panes::Pane; -use theme::ActiveTheme; - -use crate::delegate::{PaneDelegate, SettingsDelegate, TabScrollHandles}; -use crate::drag::TabDrag; -use crate::metrics::PANE_HEADER_HEIGHT; -use crate::tabs as tab_views; -use crate::tabs::file_tree::drag::FileNodeDrag; - -use super::tab; - -pub fn render( - tree: &PaneTree, - pane: &Pane, - workspace_id: usize, - workspace_path: &Path, - tab_scrolls: &TabScrollHandles, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let active_tab_id = pane.active_tab(); - let tabs = pane.tabs(); - let active_tab = tabs.iter().find(|t| t.id == active_tab_id).cloned(); - let can_close = tree.total_tabs() > 1; - let mut tab_elements: Vec = Vec::new(); - - for (i, t) in tabs.iter().enumerate() { - if i > 0 { - let prev_tab = &tabs[i - 1]; - let show_divider = prev_tab.id != active_tab_id && t.id != active_tab_id; - let divider = if show_divider { - Separator::vertical() - .h(rems(1.0)) - .color(gpui::Hsla::from(theme.border_strong)) - .into_any_element() - } else { - div() - .w(rems(0.0625)) - .h(rems(1.0)) - .flex_none() - .into_any_element() - }; - tab_elements.push(divider); - } - tab_elements.push(tab::render(workspace_id, pane, t, can_close, window, cx)); - } - - let pane_id = pane.id(); - let scroll_handle = tab_scrolls.handle(pane_id); - if tab_scrolls.is_end_anchored(pane_id) && !tabs.is_empty() { - tab_scrolls.scroll_to_index(pane_id, last_tab_scroll_index(tabs.len())); - } - let accept_file_drops = active_tab - .as_ref() - .map(|t| t.kind.as_str() != tabs::registry::FILE_TREE.id) - .unwrap_or(true); - let body = match active_tab { - Some(tab) => tab_views::render(workspace_id, workspace_path, pane_id, &tab, window, cx), - None => div().flex_1().min_h_0().into_any_element(), - }; - div() - .id(("pane", pane_id)) - .relative() - .size_full() - .min_w_0() - .min_h_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .text_color(theme.text) - .capture_any_mouse_down(cx.listener(move |this, _, _, cx| { - this.focus_pane(pane_id, cx); - })) - .child( - div() - .h(PANE_HEADER_HEIGHT) - .flex_none() - .w_full() - .flex() - .items_center() - .gap_1() - .p(rems(0.375)) - .bg(theme.bg_elevated) - .border_b_1() - .border_color(theme.border_subtle) - .overflow_hidden() - .child( - div() - .id(("tab-scroll", pane_id)) - .h_full() - .flex() - .flex_initial() - .min_w_0() - .items_center() - .gap(rems(0.125)) - .overflow_x_scroll() - .track_scroll(&scroll_handle) - .children(tab_elements), - ) - .child(render_add_tab_button(pane_id, cx)), - ) - .child(body) - .child( - div() - .absolute() - .top(PANE_HEADER_HEIGHT) - .bottom_0() - .left_0() - .right_0() - .child(render_drop_zone( - pane_id, - DropZone::Center, - accept_file_drops, - cx, - )) - .child(render_drop_zone( - pane_id, - DropZone::Left, - accept_file_drops, - cx, - )) - .child(render_drop_zone( - pane_id, - DropZone::Right, - accept_file_drops, - cx, - )) - .child(render_drop_zone( - pane_id, - DropZone::Top, - accept_file_drops, - cx, - )) - .child(render_drop_zone( - pane_id, - DropZone::Bottom, - accept_file_drops, - cx, - )), - ) - .into_any_element() -} - -fn render_add_tab_button(pane_id: usize, cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - let group_name = format!("add-tab-drop-{pane_id}"); - let accent = theme.accent; - - div() - .id(("add-tab-drop", pane_id)) - .group(group_name.clone()) - .relative() - .flex_none() - .size(rems(2.0)) - .can_drop(|drag, _, _| { - if drag.downcast_ref::().is_some() { - return true; - } - drag.downcast_ref::() - .is_some_and(|d| d.kind == NodeKind::File) - }) - .on_drop(cx.listener(move |this, drag: &TabDrag, _, cx| { - cx.stop_propagation(); - this.move_tab_to_end(drag.clone(), pane_id, cx); - })) - .on_drop(cx.listener(move |this, drag: &FileNodeDrag, _, cx| { - if drag.kind != NodeKind::File { - return; - } - let Some(path) = drag.paths.first().cloned() else { - return; - }; - cx.stop_propagation(); - this.open_file_in_pane(path, pane_id, cx); - })) - .child( - div() - .absolute() - .left(rems(-0.125)) - .top(rems(0.25)) - .bottom(rems(0.25)) - .w(rems(0.125)) - .rounded_full() - .hover(|s| s) - .group_drag_over::(group_name.clone(), move |s| s.bg(accent)) - .group_drag_over::(group_name, move |s| s.bg(accent)), - ) - .child( - Button::new(("add-tab", pane_id)) - .ghost() - .tab_stop(false) - .size(rems(2.0)) - .child(ComponentIcon::empty().path(IconName::Add.path()).small()) - .on_click(cx.listener(move |this, _, _, cx| { - this.add_tab(pane_id, tabs::registry::BLANK.id, cx); - })), - ) - .into_any_element() -} - -fn last_tab_scroll_index(tab_count: usize) -> usize { - // The scrollable strip is: tab, divider, tab, ..., tab. - // `n` tabs + `n - 1` dividers = `2 * n - 1` children. - 2 * tab_count - 2 -} - -fn drop_zone_group_name(pane_id: usize, drop_zone: DropZone) -> String { - let suffix = match drop_zone { - DropZone::Center => "center", - DropZone::Left => "left", - DropZone::Right => "right", - DropZone::Top => "top", - DropZone::Bottom => "bottom", - }; - format!("drop-zone-{pane_id}-{suffix}") -} - -fn render_drop_zone( - pane_id: usize, - drop_zone: DropZone, - accept_file_drops: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let id = match drop_zone { - DropZone::Center => 0, - DropZone::Left => 1, - DropZone::Right => 2, - DropZone::Top => 3, - DropZone::Bottom => 4, - }; - - div() - .id(("drop-zone", pane_id * 10 + id)) - .group(drop_zone_group_name(pane_id, drop_zone)) - .absolute() - .when(drop_zone == DropZone::Center, |this| { - this.top(gpui::relative(0.25)) - .bottom(gpui::relative(0.25)) - .left(gpui::relative(0.25)) - .right(gpui::relative(0.25)) - }) - .when(drop_zone == DropZone::Left, |this| { - this.top(gpui::relative(0.25)) - .bottom(gpui::relative(0.25)) - .left_0() - .w(gpui::relative(0.25)) - }) - .when(drop_zone == DropZone::Right, |this| { - this.top(gpui::relative(0.25)) - .bottom(gpui::relative(0.25)) - .right_0() - .w(gpui::relative(0.25)) - }) - .when(drop_zone == DropZone::Top, |this| { - this.top_0().left_0().right_0().h(gpui::relative(0.25)) - }) - .when(drop_zone == DropZone::Bottom, |this| { - this.bottom_0().left_0().right_0().h(gpui::relative(0.25)) - }) - .when( - !matches!(drop_zone, DropZone::Left | DropZone::Right), - |this| { - let alpha = if drop_zone == DropZone::Center { - 0.08 - } else { - 0.18 - }; - let bg = gpui::Hsla::from(theme.accent).opacity(alpha); - let this = this.drag_over::(move |s, _: &TabDrag, _, _| s.bg(bg)); - if accept_file_drops { - this.drag_over::(move |s, _: &FileNodeDrag, _, _| s.bg(bg)) - } else { - this - } - }, - ) - .when( - matches!(drop_zone, DropZone::Left | DropZone::Right), - |this| { - let group_name = drop_zone_group_name(pane_id, drop_zone); - let highlight_bg = gpui::Hsla::from(theme.accent).opacity(0.18); - let highlight = div() - .absolute() - .top(gpui::relative(-0.5)) - .bottom(gpui::relative(-0.5)) - .left_0() - .right_0() - // No-op hover forces GPUI to insert a hitbox; without it, - // group_drag_over styles are skipped and the highlight never paints. - .hover(|s| s) - .group_drag_over::(group_name.clone(), move |s| s.bg(highlight_bg)); - let highlight = if accept_file_drops { - highlight - .group_drag_over::(group_name, move |s| s.bg(highlight_bg)) - } else { - highlight - }; - this.child(highlight) - }, - ) - .can_drop(move |drag, _, _| { - if drag.downcast_ref::().is_some() { - return true; - } - accept_file_drops - && drag - .downcast_ref::() - .is_some_and(|d| d.kind == NodeKind::File) - }) - .on_drop(cx.listener(move |this, drag: &TabDrag, _, cx| { - cx.stop_propagation(); - match drop_zone { - DropZone::Center => this.move_tab_to_pane(drag.clone(), pane_id, cx), - DropZone::Left | DropZone::Right | DropZone::Top | DropZone::Bottom => { - this.split_pane(drag.clone(), pane_id, drop_zone, cx) - } - } - })) - .on_drop(cx.listener(move |this, drag: &FileNodeDrag, _, cx| { - if drag.kind != NodeKind::File { - return; - } - let Some(path) = drag.paths.first().cloned() else { - return; - }; - cx.stop_propagation(); - match drop_zone { - DropZone::Center => this.open_file_in_pane(path, pane_id, cx), - DropZone::Left | DropZone::Right | DropZone::Top | DropZone::Bottom => { - this.split_pane_with_file(path, pane_id, drop_zone, cx) - } - } - })) - .into_any_element() -} diff --git a/crates/ui/src/layout/main_content/pane_tree.rs b/crates/ui/src/layout/main_content/pane_tree.rs deleted file mode 100644 index e1b57ec2..00000000 --- a/crates/ui/src/layout/main_content/pane_tree.rs +++ /dev/null @@ -1,167 +0,0 @@ -use gpui::{ - AnyElement, Context, DragMoveEvent, IntoElement, MouseButton, SharedString, Window, div, - prelude::*, relative, rems, -}; -use std::path::Path; - -use pane_tree::{PaneNode, PaneTree, SplitAxis}; -use theme::{ActiveTheme, Theme}; - -use crate::delegate::{PaneDelegate, SettingsDelegate, TabScrollHandles}; -use crate::drag::SplitResize; - -use super::pane; - -pub fn render( - tree: &PaneTree, - node: &PaneNode, - workspace_id: usize, - workspace_path: &Path, - tab_scrolls: &TabScrollHandles, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - match node { - PaneNode::Leaf(p) => pane::render( - tree, - p, - workspace_id, - workspace_path, - tab_scrolls, - window, - cx, - ), - PaneNode::Split { - id, - axis, - ratio, - first, - second, - } => { - let split_id = *id; - let axis = *axis; - let ratio = *ratio; - div() - .id(("split", split_id)) - .size_full() - .min_w_0() - .min_h_0() - .flex() - .when(axis == SplitAxis::Row, |this| this.flex_row()) - .when(axis == SplitAxis::Column, |this| this.flex_col()) - .bg(theme.bg_root) - .on_drag_move(cx.listener( - move |this, event: &DragMoveEvent, _, cx| { - let drag = *event.drag(cx); - if drag.split_id != split_id { - return; - } - let ratio = match drag.axis { - SplitAxis::Row => { - (event.event.position.x - event.bounds.left()) - / event.bounds.size.width - } - SplitAxis::Column => { - (event.event.position.y - event.bounds.top()) - / event.bounds.size.height - } - }; - this.resize_split(drag.split_id, ratio, cx); - }, - )) - .child( - div() - .flex_none() - .min_w_0() - .min_h_0() - .when(axis == SplitAxis::Row, |this| { - this.w(relative(ratio)).h_full() - }) - .when(axis == SplitAxis::Column, |this| { - this.h(relative(ratio)).w_full() - }) - .child(render( - tree, - first, - workspace_id, - workspace_path, - tab_scrolls, - window, - cx, - )), - ) - .child(render_resize_handle(split_id, axis, &theme, cx)) - .child( - div() - .flex_1() - .min_w_0() - .min_h_0() - .when(axis == SplitAxis::Row, |this| this.h_full()) - .when(axis == SplitAxis::Column, |this| this.w_full()) - .child(render( - tree, - second, - workspace_id, - workspace_path, - tab_scrolls, - window, - cx, - )), - ) - .into_any_element() - } - } -} - -fn render_resize_handle( - split_id: usize, - axis: SplitAxis, - theme: &Theme, - cx: &mut Context, -) -> AnyElement { - let hover_bg = theme.accent; - let group_name = SharedString::from(format!("resize-{split_id}")); - div() - .id(("resize", split_id)) - .group(group_name.clone()) - .relative() - .flex_none() - .bg(theme.bg_hover) - .group_hover(group_name, move |this| this.bg(hover_bg)) - .when(axis == SplitAxis::Row, |this| this.w(rems(0.1875)).h_full()) - .when(axis == SplitAxis::Column, |this| { - this.h(rems(0.1875)).w_full() - }) - .child( - div() - .id(("resize-hit", split_id)) - .absolute() - .when(axis == SplitAxis::Row, |this| { - this.top_0() - .bottom_0() - .left(rems(-0.1875)) - .right(rems(-0.1875)) - .cursor_col_resize() - }) - .when(axis == SplitAxis::Column, |this| { - this.left_0() - .right_0() - .top(rems(-0.1875)) - .bottom(rems(-0.1875)) - .cursor_row_resize() - }) - .on_drag(SplitResize::new(split_id, axis), |resize, _, _, cx| { - cx.new(|_| *resize) - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.finish_resize_split(cx)), - ) - .on_mouse_up_out( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.finish_resize_split(cx)), - ), - ) - .into_any_element() -} diff --git a/crates/ui/src/layout/main_content/tab.rs b/crates/ui/src/layout/main_content/tab.rs deleted file mode 100644 index ab87dd04..00000000 --- a/crates/ui/src/layout/main_content/tab.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::time::Duration; - -use file_tree::NodeKind; -use gpui::{ - Animation, AnimationExt, AnyElement, Context, IntoElement, MouseButton, SharedString, Window, - div, ease_in_out, prelude::*, rems, -}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, - button::{Button, ButtonVariants}, -}; - -use file_editor::BufferStore; -use icons::{Icon, IconName}; -use panes::Pane; -use tabs::Tab; -use theme::ActiveTheme; - -use crate::delegate::{PaneDelegate, TabAnimationState}; -use crate::drag::TabDrag; -use crate::metrics::{TAB_ANIMATION_DURATION_MS, TAB_HEIGHT, TAB_RADIUS}; -use crate::tabs::file_tree::drag::FileNodeDrag; - -const TAB_HORIZONTAL_PADDING_REM: f32 = 1.0; -const TAB_ICON_WIDTH_REM: f32 = 1.0; -const TAB_CLOSE_BUTTON_WIDTH_REM: f32 = 1.25; -const TAB_CONTENT_GAP_REM: f32 = 0.5; -const TAB_DIRTY_DOT_WIDTH_REM: f32 = 0.375; - -pub fn render( - workspace_id: usize, - pane: &Pane, - tab: &Tab, - can_close: bool, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let pane_id = pane.id(); - let id = tab.id; - let is_active = pane.active_tab() == id; - let hover_group = SharedString::from(format!("tab-{pane_id}-{id}")); - let title = SharedString::from(tab.title()); - let icon_name = crate::tabs::icon_for_tab(tab); - let accent = theme.accent; - let is_dirty = tab - .path - .as_deref() - .is_some_and(|path| BufferStore::is_path_dirty(path, cx)); - let animation_phase = cx - .try_global::() - .and_then(|state| state.phase(workspace_id, pane_id, id)); - let target_width = tab_width_rems(window, title.as_ref(), is_dirty); - - let content = div() - .h_full() - .flex_none() - .flex() - .items_center() - .gap_2() - .px_2() - .child( - Icon::new(icon_name) - .size_rem(1.0) - .color(if is_active { - theme.text - } else { - theme.text_subtle - }) - .into_any_element(), - ) - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(title.clone()), - ) - .when(is_dirty, |this| { - this.child( - div() - .size(rems(0.375)) - .flex_none() - .rounded_full() - .bg(theme.dirty), - ) - }) - .child( - Button::new(("close-tab", id)) - .ghost() - .tab_stop(false) - .size(rems(1.25)) - .invisible() - .when(can_close, |this| { - this.group_hover(hover_group.clone(), |this| this.visible()) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.close_tab(pane_id, id, cx); - })) - }) - .child(ComponentIcon::empty().path(IconName::Close.path()).small()), - ); - let content = if let Some(phase) = animation_phase { - let animation_id = SharedString::from(format!( - "tab-content-{workspace_id}-{pane_id}-{id}-{}", - phase.key() - )); - content - .with_animation(animation_id, tab_animation(), move |this, delta| { - this.opacity(phase.progress(delta)) - }) - .into_any_element() - } else { - content.into_any_element() - }; - - let tab = div() - .id(("tab", id)) - .group(hover_group.clone()) - .relative() - .flex() - .flex_none() - .items_center() - .h(TAB_HEIGHT) - .w(rems(target_width)) - .overflow_hidden() - .rounded(TAB_RADIUS) - .when(is_active, |this| this.bg(theme.bg_selected)) - .text_color(if is_active { - theme.text_emphasis - } else { - theme.text_muted - }) - .text_sm() - .hover(move |this| this.bg(theme.bg_hover)) - .can_drop(move |drag, _, _| { - if drag - .downcast_ref::() - .is_some_and(|drag| drag.id != id) - { - return true; - } - drag.downcast_ref::() - .is_some_and(|d| d.kind == NodeKind::File) - }) - .on_drop(cx.listener(move |this, drag: &TabDrag, _, cx| { - cx.stop_propagation(); - this.move_tab_before(drag.clone(), pane_id, id, cx); - })) - .on_drop(cx.listener(move |this, drag: &FileNodeDrag, _, cx| { - if drag.kind != NodeKind::File { - return; - } - let Some(path) = drag.paths.first().cloned() else { - return; - }; - cx.stop_propagation(); - this.open_file_before(path, pane_id, id, cx); - })) - .on_drag( - TabDrag::new(id, pane_id, title.clone(), icon_name), - |drag, position, _, cx| cx.new(|_| drag.clone().position(position)), - ) - .when(can_close, |this| { - this.on_mouse_down( - MouseButton::Middle, - cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.close_tab(pane_id, id, cx); - }), - ) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.select_tab(pane_id, id, cx); - })) - .child( - div() - .absolute() - .left(rems(0.0)) - .top(rems(0.25)) - .bottom(rems(0.25)) - .w(rems(0.125)) - .rounded_full() - // No-op hover forces GPUI to insert a hitbox; without it, - // group_drag_over styles are skipped and the line never paints. - .hover(|s| s) - .group_drag_over::(hover_group.clone(), move |s| s.bg(accent)) - .group_drag_over::(hover_group.clone(), move |s| s.bg(accent)), - ) - .child(content); - - if let Some(phase) = animation_phase { - let animation_id = SharedString::from(format!( - "tab-width-{workspace_id}-{pane_id}-{id}-{}", - phase.key() - )); - tab.with_animation(animation_id, tab_animation(), move |this, delta| { - this.w(rems(target_width * phase.progress(delta))) - }) - .into_any_element() - } else { - tab.into_any_element() - } -} - -fn tab_animation() -> Animation { - Animation::new(Duration::from_millis(TAB_ANIMATION_DURATION_MS)).with_easing(ease_in_out) -} - -fn tab_width_rems(window: &mut Window, title: &str, is_dirty: bool) -> f32 { - let fixed_content_width = TAB_HORIZONTAL_PADDING_REM - + TAB_ICON_WIDTH_REM - + TAB_CLOSE_BUTTON_WIDTH_REM - + (2.0 * TAB_CONTENT_GAP_REM); - let dirty_indicator_width = if is_dirty { - TAB_DIRTY_DOT_WIDTH_REM + TAB_CONTENT_GAP_REM - } else { - 0.0 - }; - fixed_content_width + dirty_indicator_width + measure_text_rems(window, title) -} - -fn measure_text_rems(window: &mut Window, text: &str) -> f32 { - let style = window.text_style(); - let run = style.to_run(text.len()); - let rem_size = window.rem_size(); - let font_size = rem_size * 0.875_f32; - let layout = window - .text_system() - .layout_line(text, font_size, &[run], None); - f32::from(layout.width) / f32::from(rem_size) -} diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs deleted file mode 100644 index ed268355..00000000 --- a/crates/ui/src/layout/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod header; -pub mod landing; -pub mod main_content; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs deleted file mode 100644 index 304ec382..00000000 --- a/crates/ui/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod delegate; -pub mod drag; -pub mod layout; -pub mod metrics; -pub mod pane_tree_actions; -pub mod tabs; - -pub use delegate::*; -pub use drag::*; -pub use pane_tree_actions::*; diff --git a/crates/ui/src/metrics.rs b/crates/ui/src/metrics.rs deleted file mode 100644 index 920346c5..00000000 --- a/crates/ui/src/metrics.rs +++ /dev/null @@ -1,6 +0,0 @@ -use gpui::Rems; - -pub const TAB_HEIGHT: Rems = Rems(2.0); -pub const TAB_RADIUS: Rems = Rems(0.375); -pub const TAB_ANIMATION_DURATION_MS: u64 = 120; -pub const PANE_HEADER_HEIGHT: Rems = Rems(2.75); diff --git a/crates/ui/src/pane_tree_actions.rs b/crates/ui/src/pane_tree_actions.rs deleted file mode 100644 index 0e12ea10..00000000 --- a/crates/ui/src/pane_tree_actions.rs +++ /dev/null @@ -1,47 +0,0 @@ -use gpui::{Context, InteractiveElement, actions}; -use pane_tree::PaneTree; -use tabs::{Tab, registry}; - -actions!(pane_tree, [CloseTab, NewTab]); - -pub trait PaneTreeActionDelegate: Sized + 'static { - fn with_active_tree(&mut self, cx: &mut Context, f: impl FnOnce(&mut PaneTree) -> bool); - - fn close_active_tab(&mut self, cx: &mut Context) { - self.with_active_tree(cx, |tree| tree.close_active_tab()); - } - - fn on_tab_appended( - &mut self, - _pane_id: usize, - _tab_id: usize, - _new_tab_count: usize, - _cx: &mut Context, - ) { - } -} - -pub trait WirePaneTreeActions: Sized { - fn wire_pane_tree_actions(self, cx: &mut Context) -> Self; -} - -impl WirePaneTreeActions for E { - fn wire_pane_tree_actions(self, cx: &mut Context) -> Self { - self.on_action(cx.listener(|this, _: &CloseTab, _, cx| { - this.close_active_tab(cx); - })) - .on_action(cx.listener(|this, _: &NewTab, _, cx| { - let mut appended = None; - this.with_active_tree(cx, |tree| { - let pane_id = tree.active_pane_id(); - let tab_id = tree.next_tab_id(); - let result = tree.append_new_tab(pane_id, |id| Tab::new(id, ®istry::BLANK)); - appended = result.map(|(pane_id, count)| (pane_id, tab_id, count)); - result.is_some() - }); - if let Some((pane_id, tab_id, count)) = appended { - this.on_tab_appended(pane_id, tab_id, count, cx); - } - })) - } -} diff --git a/crates/ui/src/tabs/blank/mod.rs b/crates/ui/src/tabs/blank/mod.rs deleted file mode 100644 index ced218c5..00000000 --- a/crates/ui/src/tabs/blank/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -use gpui::{AnyElement, Context, IntoElement, SharedString, div, prelude::*, rems}; -use gpui_component::{ - Icon as ComponentIcon, - button::{Button, ButtonVariants}, -}; - -use tabs::{TabKind, registry}; -use theme::ActiveTheme; - -use crate::delegate::PaneDelegate; - -pub fn render(pane_id: usize, tab_id: usize, cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - let buttons: Vec = registry::ALL - .iter() - .copied() - .filter(|kind| !kind.is_hidden) - .map(|kind| render_button(pane_id, tab_id, kind, cx)) - .collect(); - - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_4() - .text_color(theme.text) - .child( - div() - .text_xl() - .text_color(theme.text_emphasis) - .child("Open a tab"), - ) - .child( - div() - .flex() - .flex_wrap() - .items_center() - .justify_center() - .gap_2() - .max_w(rems(40.0)) - .children(buttons), - ) - .into_any_element() -} - -fn render_button( - pane_id: usize, - tab_id: usize, - kind: &'static TabKind, - cx: &mut Context, -) -> AnyElement { - let kind_id = kind.id; - Button::new(SharedString::new_static(kind_id)) - .secondary() - .icon(ComponentIcon::empty().path(super::icon_for_kind(kind.id).path())) - .label(kind.name) - .on_click(cx.listener(move |this, _, _, cx| { - this.replace_tab_kind(pane_id, tab_id, kind_id, cx); - })) - .into_any_element() -} diff --git a/crates/ui/src/tabs/diff.rs b/crates/ui/src/tabs/diff.rs deleted file mode 100644 index 118c9591..00000000 --- a/crates/ui/src/tabs/diff.rs +++ /dev/null @@ -1,2159 +0,0 @@ -use std::collections::{HashMap, HashSet, hash_map::DefaultHasher}; -use std::hash::{Hash, Hasher}; -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::sync::Arc; - -use gpui::{ - AnyElement, App, Context, Entity, Global, HighlightStyle, IntoElement, ListSizingBehavior, - ScrollStrategy, SharedString, StyledText, Task, Window, div, prelude::*, px, rems, size, -}; -use gpui_component::{ - VirtualListScrollHandle, WindowExt, - highlighter::{HighlightTheme, SyntaxHighlighter}, - input::Rope, - notification::Notification, - v_virtual_list, -}; -use icons::{Icon, IconName}; -use kosmos_git::{ - ConflictBlock, ConflictLine, ConflictResolution, DiffLine, DiffLineKind, FileChangeKind, - RepositoryDiff, -}; -use settings::{ActiveSettings, SettingValue}; -use tabs::Tab; -use theme::ActiveTheme; - -use file_editor::BufferStore; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; - -const FONT_FAMILY: &str = "DejaVu Sans Mono"; -const DIFF_FILE_ROW_HEIGHT_REM: f32 = 2.375; -const DIFF_ROW_HEIGHT_REM: f32 = 1.25; -const DIFF_GAP_ROW_HEIGHT_REM: f32 = 2.0; -const LINE_NUMBER_WIDTH_REM: f32 = 3.0; -const SIGN_WIDTH_REM: f32 = 1.25; -const CARD_MARGIN_X_REM: f32 = 0.5; -const DIFF_CODE_TEXT_SIZE_REM: f32 = 0.8125; -const DIFF_MONO_CHAR_WIDTH_FACTOR: f32 = 0.62; - -type LineHighlightKey = (u8, Option, Option); -type LineHighlights = Vec<(Range, HighlightStyle)>; -type PreparedFileCache = HashMap; - -struct DiffUiState { - entries: HashMap, - wrap_width_px: Option, -} - -struct DiffWorkspaceState { - diff: Option>, - diff_generation: u64, - loading: bool, - error: Option, - generation: u64, - task: Option>, - prepared: Option, - prepare_request: Option, - prepare_task: Option>, - scroll_handle: VirtualListScrollHandle, - pending_focus: Option, - active_file: Option, -} - -impl Default for DiffWorkspaceState { - fn default() -> Self { - Self { - diff: None, - diff_generation: 0, - loading: false, - error: None, - generation: 0, - task: None, - prepared: None, - prepare_request: None, - prepare_task: None, - scroll_handle: VirtualListScrollHandle::new(), - pending_focus: None, - active_file: None, - } - } -} - -impl Default for DiffUiState { - fn default() -> Self { - Self { - entries: HashMap::new(), - wrap_width_px: None, - } - } -} - -impl Global for DiffUiState {} - -#[derive(Clone)] -struct PreparedDiffView { - generation: u64, - theme_id: &'static str, - work_dir: PathBuf, - rows: Rc>>, - row_heights: Rc>, - highlight_cache: Rc, - file_cache: PreparedFileCache, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -struct DiffPrepareRequest { - generation: u64, - theme_id: &'static str, -} - -struct PreparedDiffData { - generation: u64, - theme_id: &'static str, - work_dir: PathBuf, - rows: Vec>, - row_heights: Vec, - highlight_cache: DiffHighlightCache, - file_cache: PreparedFileCache, -} - -#[derive(Clone)] -struct PreparedFileEntry { - fingerprint: u64, - rows: Arc>>, - row_heights: Arc>, - highlights: Arc>, -} - -#[derive(Clone, Default)] -struct DiffHighlightCache { - lines: HashMap>>, -} - -impl DiffHighlightCache { - fn highlights(&self, path: &str, key: &LineHighlightKey) -> Option<&[LineHighlightsItem]> { - self.lines - .get(path) - .and_then(|lines| lines.get(key)) - .map(Vec::as_slice) - } -} - -type LineHighlightsItem = (Range, HighlightStyle); - -#[derive(Clone)] -enum DiffRow { - File { - path: String, - old_path: Option, - kind: FileChangeKind, - insertions: usize, - deletions: usize, - binary: bool, - }, - Unchanged { - path: String, - lines: usize, - }, - Line { - path: String, - can_open: bool, - line: DiffLine, - }, - ConflictActions { - path: String, - start_line: usize, - current_label: String, - incoming_label: String, - }, - ConflictMarker { - path: String, - line: usize, - text: String, - side: Option, - }, - ConflictLine { - path: String, - side: ConflictSide, - line: ConflictLine, - }, - Message { - path: String, - text: String, - }, -} - -#[derive(Clone, Copy)] -enum HighlightSide { - Old, - New, -} - -#[derive(Clone, Copy)] -enum ConflictSide { - Current, - Incoming, -} - -pub fn render( - workspace_path: &Path, - tab: &Tab, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let root = tab.path.as_deref().unwrap_or(workspace_path).to_path_buf(); - ensure_diff(&root, cx); - - let (diff, diff_generation, loading, error, scroll_handle, active_file) = { - let state = cx.global::(); - let entry = state.entries.get(&root); - ( - entry.and_then(|entry| entry.diff.clone()), - entry.map(|entry| entry.diff_generation).unwrap_or_default(), - entry.is_some_and(|entry| entry.loading), - entry.and_then(|entry| entry.error.clone()), - entry - .map(|entry| entry.scroll_handle.clone()) - .unwrap_or_else(VirtualListScrollHandle::new), - entry.and_then(|entry| entry.active_file.clone()), - ) - }; - let theme = *cx.theme(); - let soft_wrap = diff_soft_wrap_enabled(cx); - let content = match (loading, error, diff) { - (true, _, None) => centered_state("Loading diff", cx), - (_, Some(error), None) => centered_state(error, cx), - (_, _, Some(diff)) if diff.is_empty() => centered_state("No changes", cx), - (_, _, Some(diff)) => { - let prepared = ensure_prepared_diff(&root, diff, theme.id, cx); - match prepared { - Some(prepared) if prepared.rows.is_empty() => { - centered_state("No textual changes", cx) - } - Some(prepared) => { - if prepared.generation == diff_generation { - apply_pending_focus(&root, prepared.rows.as_slice(), cx); - } - diff_rows( - prepared.work_dir, - prepared.rows, - prepared.row_heights, - scroll_handle, - prepared.highlight_cache, - cx.entity().clone(), - active_file, - soft_wrap, - window, - cx, - ) - } - None => centered_state("Preparing diff", cx), - } - } - (_, _, None) => centered_state("Loading diff", cx), - }; - - div() - .size_full() - .min_h_0() - .min_w_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .text_color(theme.text) - .child(content) - .into_any_element() -} - -pub fn request_focus(root: PathBuf, file_path: String, cx: &mut App) { - ensure_state(cx); - cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - entry.pending_focus = Some(file_path.clone()); - entry.active_file = Some(file_path); - }); - cx.refresh_windows(); -} - -fn ensure_state(cx: &mut App) { - if cx.try_global::().is_none() { - cx.set_global(DiffUiState::default()); - } -} - -fn entry_for_root<'a>(state: &'a mut DiffUiState, root: &Path) -> &'a mut DiffWorkspaceState { - state.entries.entry(root.to_path_buf()).or_default() -} - -fn ensure_prepared_diff( - root: &Path, - diff: Arc, - theme_id: &'static str, - cx: &mut Context, -) -> Option { - ensure_state(cx); - let (request, stale_prepared, previous_cache) = { - let state = cx.global::(); - let Some(entry) = state.entries.get(root) else { - return None; - }; - let generation = entry.diff_generation; - if let Some(prepared) = &entry.prepared - && prepared.generation == generation - && prepared.theme_id == theme_id - { - return Some(prepared.clone()); - } - - let stale_prepared = entry - .prepared - .as_ref() - .filter(|prepared| prepared.theme_id == theme_id) - .cloned(); - let previous_cache = stale_prepared - .as_ref() - .map(|prepared| prepared.file_cache.clone()) - .unwrap_or_default(); - - ( - DiffPrepareRequest { - generation, - theme_id, - }, - stale_prepared, - previous_cache, - ) - }; - - let should_prepare = cx.update_global::(|state, _| { - let Some(entry) = state.entries.get_mut(root) else { - return false; - }; - if entry.prepare_request == Some(request) { - return false; - } - entry.prepare_request = Some(request); - true - }); - - if should_prepare { - let root = root.to_path_buf(); - let task_root = root.clone(); - let highlight_theme = gpui_component::Theme::global(cx).highlight_theme.clone(); - let task = cx.spawn(async move |this, cx| { - let prepared = cx - .background_executor() - .spawn(async move { - build_prepared_diff(&diff, request, &highlight_theme, previous_cache) - }) - .await; - let _ = this.update(cx, |_, cx| { - apply_prepared_diff(&task_root, request, prepared, cx); - cx.notify(); - }); - }); - - cx.update_global::(|state, _| { - if let Some(entry) = state.entries.get_mut(&root) { - entry.prepare_task = Some(task); - } - }); - } - - stale_prepared -} - -fn apply_prepared_diff( - root: &Path, - request: DiffPrepareRequest, - prepared: PreparedDiffData, - cx: &mut App, -) { - cx.update_global::(|state, _| { - let Some(entry) = state.entries.get_mut(root) else { - return; - }; - if entry.diff_generation != request.generation || entry.prepare_request != Some(request) { - return; - } - - entry.prepared = Some(PreparedDiffView { - generation: prepared.generation, - theme_id: prepared.theme_id, - work_dir: prepared.work_dir, - rows: Rc::new(prepared.rows), - row_heights: Rc::new(prepared.row_heights), - highlight_cache: Rc::new(prepared.highlight_cache), - file_cache: prepared.file_cache, - }); - entry.prepare_request = None; - entry.prepare_task = None; - }); -} - -fn ensure_diff(root: &Path, cx: &mut Context) { - ensure_state(cx); - let needs_refresh = { - let state = cx.global::(); - state - .entries - .get(root) - .is_none_or(|entry| !entry.loading && entry.diff.is_none() && entry.error.is_none()) - }; - if needs_refresh { - refresh_diff(root.to_path_buf(), false, cx); - } -} - -pub fn refresh_if_loaded( - root: PathBuf, - notify_now: bool, - cx: &mut Context, -) { - if should_refresh_loaded(&root, cx) { - refresh_diff(root, notify_now, cx); - } -} - -pub fn refresh_paths_if_loaded( - root: PathBuf, - paths: Vec, - notify_now: bool, - cx: &mut Context, -) { - if !should_refresh_loaded(&root, cx) { - return; - } - - let can_refresh_paths = cx - .try_global::() - .and_then(|state| state.entries.get(&root)) - .is_some_and(|entry| entry.diff.is_some()); - - if paths.is_empty() || !can_refresh_paths { - refresh_diff(root, notify_now, cx); - } else { - refresh_diff_paths(root, paths, notify_now, cx); - } -} - -pub fn prewarm( - root: PathBuf, - notify_now: bool, - cx: &mut Context, -) { - ensure_state(cx); - refresh_diff(root, notify_now, cx); -} - -pub fn prewarm_paths( - root: PathBuf, - paths: Vec, - notify_now: bool, - cx: &mut Context, -) { - ensure_state(cx); - let can_refresh_paths = cx - .try_global::() - .and_then(|state| state.entries.get(&root)) - .is_some_and(|entry| entry.diff.is_some()); - - if paths.is_empty() || !can_refresh_paths { - refresh_diff(root, notify_now, cx); - } else { - refresh_diff_paths(root, paths, notify_now, cx); - } -} - -pub fn refresh_if_loaded_app(root: PathBuf, notify_now: bool, cx: &mut App) { - if !should_refresh_loaded(&root, cx) { - return; - } - - let generation = cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - entry.loading = true; - entry.generation = entry.generation.wrapping_add(1); - entry.generation - }); - - if notify_now { - cx.refresh_windows(); - } - - let task_root = root.clone(); - let apply_root = root.clone(); - let task = cx.spawn(async move |cx| { - let result = cx - .background_executor() - .spawn(async move { RepositoryDiff::discover(task_root) }) - .await; - let _ = cx.update(|cx| { - apply_diff_result(&apply_root, generation, result, cx); - cx.refresh_windows(); - }); - }); - - cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - entry.task = Some(task); - }); -} - -fn should_refresh_loaded(root: &Path, cx: &App) -> bool { - cx.try_global::() - .and_then(|state| state.entries.get(root)) - .is_some_and(|entry| { - entry.diff.is_some() - || entry.error.is_some() - || entry.loading - || entry.active_file.is_some() - || entry.pending_focus.is_some() - }) -} - -fn refresh_diff( - root: PathBuf, - notify_now: bool, - cx: &mut Context, -) { - let theme_id = cx.theme().id; - let highlight_theme = gpui_component::Theme::global(cx).highlight_theme.clone(); - let (generation, previous_cache) = cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - let previous_cache = prepared_file_cache(entry.prepared.as_ref(), theme_id); - entry.loading = true; - entry.generation = entry.generation.wrapping_add(1); - (entry.generation, previous_cache) - }); - let request = DiffPrepareRequest { - generation, - theme_id, - }; - - if notify_now { - cx.notify(); - } - - let task_root = root.clone(); - let apply_root = root.clone(); - let task = cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { - RepositoryDiff::discover(task_root).map(|diff| { - let prepared = - build_prepared_diff(&diff, request, &highlight_theme, previous_cache); - (diff, prepared) - }) - }) - .await; - let _ = this.update(cx, |_, cx| { - apply_prepared_diff_result(&apply_root, generation, result, cx); - cx.notify(); - }); - }); - - cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - entry.task = Some(task); - }); -} - -fn refresh_diff_paths( - root: PathBuf, - paths: Vec, - notify_now: bool, - cx: &mut Context, -) { - let theme_id = cx.theme().id; - let highlight_theme = gpui_component::Theme::global(cx).highlight_theme.clone(); - let (generation, current_diff, previous_cache) = - cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - let current_diff = entry.diff.clone(); - let previous_cache = prepared_file_cache(entry.prepared.as_ref(), theme_id); - entry.loading = true; - entry.generation = entry.generation.wrapping_add(1); - (entry.generation, current_diff, previous_cache) - }); - let request = DiffPrepareRequest { - generation, - theme_id, - }; - - if notify_now { - cx.notify(); - } - - let task_root = root.clone(); - let task_paths = paths.clone(); - let apply_root = root.clone(); - let task = cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { - RepositoryDiff::discover_paths(task_root, &task_paths).map(|partial| { - let diff = merge_partial_diff(current_diff.as_deref(), partial, &task_paths); - let prepared = - build_prepared_diff(&diff, request, &highlight_theme, previous_cache); - (diff, prepared) - }) - }) - .await; - let _ = this.update(cx, |_, cx| { - apply_prepared_diff_result(&apply_root, generation, result, cx); - cx.notify(); - }); - }); - - cx.update_global::(|state, _| { - let entry = entry_for_root(state, &root); - entry.task = Some(task); - }); -} - -fn apply_diff_result( - root: &Path, - generation: u64, - result: Result, - cx: &mut App, -) { - cx.update_global::(|state, _| { - let Some(entry) = state.entries.get_mut(root) else { - return; - }; - if entry.generation != generation { - return; - } - entry.loading = false; - match result { - Ok(diff) => { - entry.diff = Some(Arc::new(diff)); - entry.diff_generation = generation; - entry.prepare_request = None; - entry.error = None; - } - Err(error) => { - entry.diff = None; - entry.diff_generation = 0; - entry.prepared = None; - entry.prepare_request = None; - entry.error = Some(error.to_string()); - } - } - }); -} - -fn apply_prepared_diff_result( - root: &Path, - generation: u64, - result: Result<(RepositoryDiff, PreparedDiffData), kosmos_git::Error>, - cx: &mut App, -) { - cx.update_global::(|state, _| { - let Some(entry) = state.entries.get_mut(root) else { - return; - }; - if entry.generation != generation { - return; - } - entry.loading = false; - match result { - Ok((diff, prepared)) => { - entry.diff = Some(Arc::new(diff)); - entry.diff_generation = generation; - entry.prepared = Some(PreparedDiffView { - generation: prepared.generation, - theme_id: prepared.theme_id, - work_dir: prepared.work_dir, - rows: Rc::new(prepared.rows), - row_heights: Rc::new(prepared.row_heights), - highlight_cache: Rc::new(prepared.highlight_cache), - file_cache: prepared.file_cache, - }); - entry.prepare_request = None; - entry.prepare_task = None; - entry.error = None; - } - Err(error) => { - entry.diff = None; - entry.diff_generation = 0; - entry.prepared = None; - entry.prepare_request = None; - entry.error = Some(error.to_string()); - } - } - }); -} - -fn merge_partial_diff( - current: Option<&RepositoryDiff>, - partial: RepositoryDiff, - paths: &[String], -) -> RepositoryDiff { - let Some(current) = current else { - return partial; - }; - - let affected_paths = paths.iter().map(String::as_str).collect::>(); - let mut replacements = partial - .files - .into_iter() - .map(|file| (file.path.clone(), file)) - .collect::>(); - let mut files = Vec::with_capacity(current.files.len() + replacements.len()); - - for file in ¤t.files { - if file_matches_paths(file, &affected_paths) { - if let Some(replacement) = replacements.remove(&file.path) { - files.push(replacement); - } - continue; - } - - files.push(file.clone()); - } - - files.extend(replacements.into_values()); - - RepositoryDiff { - work_dir: partial.work_dir, - files, - } -} - -fn file_matches_paths(file: &kosmos_git::FileDiff, paths: &HashSet<&str>) -> bool { - paths.contains(file.path.as_str()) - || file - .old_path - .as_deref() - .is_some_and(|path| paths.contains(path)) -} - -fn apply_pending_focus( - root: &Path, - rows: &[Arc], - cx: &mut Context, -) { - cx.update_global::(|state, _| { - let Some(entry) = state.entries.get_mut(root) else { - return; - }; - let Some(path) = entry.pending_focus.clone() else { - return; - }; - let Some(ix) = rows.iter().position(|row| row.path() == path.as_str()) else { - return; - }; - entry.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); - entry.pending_focus = None; - entry.active_file = Some(path); - }); -} - -fn diff_soft_wrap_enabled(cx: &mut Context) -> bool { - cx.settings() - .get(file_editor::SOFT_WRAP_SETTING_ID) - .and_then(SettingValue::as_bool) - .unwrap_or(false) -} - -fn prepared_file_cache( - prepared: Option<&PreparedDiffView>, - theme_id: &'static str, -) -> PreparedFileCache { - prepared - .filter(|prepared| prepared.theme_id == theme_id) - .map(|prepared| prepared.file_cache.clone()) - .unwrap_or_default() -} - -fn build_prepared_diff( - diff: &RepositoryDiff, - request: DiffPrepareRequest, - highlight_theme: &HighlightTheme, - previous_cache: PreparedFileCache, -) -> PreparedDiffData { - let mut rows = Vec::new(); - let mut row_heights = Vec::new(); - let mut highlight_lines = HashMap::new(); - let mut file_cache = HashMap::new(); - - for file in &diff.files { - let fingerprint = file_fingerprint(file); - let entry = previous_cache - .get(&file.path) - .filter(|entry| entry.fingerprint == fingerprint) - .cloned() - .unwrap_or_else(|| prepare_file_diff(file, fingerprint, highlight_theme)); - - rows.extend(entry.rows.iter().cloned()); - row_heights.extend(entry.row_heights.iter().copied()); - if !entry.highlights.is_empty() { - highlight_lines.insert(file.path.clone(), entry.highlights.clone()); - } - file_cache.insert(file.path.clone(), entry); - } - - PreparedDiffData { - generation: request.generation, - theme_id: request.theme_id, - work_dir: diff.work_dir.clone(), - rows, - row_heights, - highlight_cache: DiffHighlightCache { - lines: highlight_lines, - }, - file_cache, - } -} - -fn prepare_file_diff( - file: &kosmos_git::FileDiff, - fingerprint: u64, - highlight_theme: &HighlightTheme, -) -> PreparedFileEntry { - let rows = flatten_file_diff(file); - let row_heights = rows - .iter() - .map(|row| diff_row_height(row.as_ref(), false, 1)) - .collect(); - let highlights = build_file_highlight_cache(file, highlight_theme); - - PreparedFileEntry { - fingerprint, - rows: Arc::new(rows), - row_heights: Arc::new(row_heights), - highlights: Arc::new(highlights), - } -} - -fn build_file_highlight_cache( - file: &kosmos_git::FileDiff, - highlight_theme: &HighlightTheme, -) -> HashMap { - if file.binary || (file.hunks.is_empty() && file.conflicts.is_empty()) { - return HashMap::new(); - } - - let language = language::from_path(Path::new(&file.path)) - .map(|language| component_highlighter_language(language.as_str()).to_string()) - .unwrap_or_else(|| "plaintext".to_string()); - let mut line_highlights = HashMap::new(); - highlight_file_side( - file, - HighlightSide::Old, - &language, - highlight_theme, - &mut line_highlights, - ); - highlight_file_side( - file, - HighlightSide::New, - &language, - highlight_theme, - &mut line_highlights, - ); - highlight_conflict_side( - file, - ConflictSide::Current, - &language, - highlight_theme, - &mut line_highlights, - ); - highlight_conflict_side( - file, - ConflictSide::Incoming, - &language, - highlight_theme, - &mut line_highlights, - ); - - line_highlights -} - -fn highlight_conflict_side( - file: &kosmos_git::FileDiff, - side: ConflictSide, - language: &str, - highlight_theme: &HighlightTheme, - line_highlights: &mut HashMap, -) { - let mut source = String::new(); - let mut line_ranges = Vec::new(); - - for conflict in &file.conflicts { - let lines = match side { - ConflictSide::Current => &conflict.current, - ConflictSide::Incoming => &conflict.incoming, - }; - for line in lines { - let start = source.len(); - source.push_str(&line.text); - let end = source.len(); - line_ranges.push((conflict_line_highlight_key(side, line.line), start..end)); - source.push('\n'); - } - } - - if source.is_empty() { - return; - } - - let rope = Rope::from_str(&source); - let mut highlighter = SyntaxHighlighter::new(language); - let _ = highlighter.update(None, &rope, None); - let styles = highlighter.styles(&(0..source.len()), highlight_theme); - - for (key, range) in line_ranges { - let highlights = highlights_for_line(&styles, range); - if !highlights.is_empty() { - line_highlights.insert(key, highlights); - } - } -} - -fn highlight_file_side( - file: &kosmos_git::FileDiff, - side: HighlightSide, - language: &str, - highlight_theme: &HighlightTheme, - line_highlights: &mut HashMap, -) { - let mut source = String::new(); - let mut line_ranges = Vec::new(); - - for hunk in &file.hunks { - for line in &hunk.lines { - let include = match side { - HighlightSide::Old => line.old_line.is_some(), - HighlightSide::New => line.new_line.is_some(), - }; - if !include { - continue; - } - - let start = source.len(); - source.push_str(&line.text); - let end = source.len(); - line_ranges.push((line_highlight_key(line), start..end)); - source.push('\n'); - } - } - - if source.is_empty() { - return; - } - - let rope = Rope::from_str(&source); - let mut highlighter = SyntaxHighlighter::new(language); - let _ = highlighter.update(None, &rope, None); - let styles = highlighter.styles(&(0..source.len()), highlight_theme); - - for (key, range) in line_ranges { - let highlights = highlights_for_line(&styles, range); - if !highlights.is_empty() { - line_highlights.insert(key, highlights); - } - } -} - -fn highlights_for_line(styles: &[LineHighlightsItem], line_range: Range) -> LineHighlights { - if line_range.is_empty() { - return Vec::new(); - } - - styles - .iter() - .filter_map(|(range, style)| { - let start = range.start.max(line_range.start); - let end = range.end.min(line_range.end); - (start < end).then(|| { - ( - start - line_range.start..end - line_range.start, - style.clone(), - ) - }) - }) - .collect() -} - -fn highlighted_code_text(text: String, highlights: Option<&[LineHighlightsItem]>) -> AnyElement { - match highlights { - Some(highlights) if !highlights.is_empty() => StyledText::new(text) - .with_highlights(highlights.iter().cloned()) - .into_any_element(), - _ => StyledText::new(text).into_any_element(), - } -} - -fn file_fingerprint(file: &kosmos_git::FileDiff) -> u64 { - let mut hasher = DefaultHasher::new(); - file.old_path.hash(&mut hasher); - file.path.hash(&mut hasher); - file_change_kind_key(file.kind).hash(&mut hasher); - file.binary.hash(&mut hasher); - file.hunks.len().hash(&mut hasher); - for hunk in &file.hunks { - hunk.old_start.hash(&mut hasher); - hunk.old_lines.hash(&mut hasher); - hunk.new_start.hash(&mut hasher); - hunk.new_lines.hash(&mut hasher); - hunk.header.hash(&mut hasher); - hunk.lines.len().hash(&mut hasher); - for line in &hunk.lines { - diff_line_kind_key(line.kind).hash(&mut hasher); - line.old_line.hash(&mut hasher); - line.new_line.hash(&mut hasher); - line.text.hash(&mut hasher); - } - } - file.conflicts.len().hash(&mut hasher); - for conflict in &file.conflicts { - conflict.start_line.hash(&mut hasher); - conflict.separator_line.hash(&mut hasher); - conflict.end_line.hash(&mut hasher); - conflict.current_label.hash(&mut hasher); - conflict.incoming_label.hash(&mut hasher); - for line in &conflict.current { - line.line.hash(&mut hasher); - line.text.hash(&mut hasher); - } - for line in &conflict.incoming { - line.line.hash(&mut hasher); - line.text.hash(&mut hasher); - } - } - hasher.finish() -} - -fn file_change_kind_key(kind: FileChangeKind) -> u8 { - match kind { - FileChangeKind::Created => 0, - FileChangeKind::Modified => 1, - FileChangeKind::Deleted => 2, - FileChangeKind::Renamed => 3, - FileChangeKind::Conflicted => 4, - } -} - -fn line_highlight_key(line: &DiffLine) -> LineHighlightKey { - (diff_line_kind_key(line.kind), line.old_line, line.new_line) -} - -fn conflict_line_highlight_key(side: ConflictSide, line: usize) -> LineHighlightKey { - let side_key = match side { - ConflictSide::Current => 3, - ConflictSide::Incoming => 4, - }; - (side_key, None, Some(line)) -} - -fn diff_line_kind_key(kind: DiffLineKind) -> u8 { - match kind { - DiffLineKind::Context => 0, - DiffLineKind::Added => 1, - DiffLineKind::Removed => 2, - } -} - -fn component_highlighter_language(language: &str) -> &str { - match language { - "shellscript" => "bash", - "typescriptreact" => "tsx", - "javascriptreact" => "javascript", - other => other, - } -} - -fn accept_conflict( - root: PathBuf, - path: String, - start_line: usize, - resolution: ConflictResolution, - cx: &mut Context, -) { - let absolute_path = root.join(&path); - match resolve_conflict_at_path(&absolute_path, start_line, resolution, cx) { - Ok(()) => { - push_diff_notification(cx, Notification::success("Conflict updated")); - refresh_diff(root, true, cx); - } - Err(error) => { - push_diff_notification( - cx, - Notification::error(error).title("Resolve conflict failed"), - ); - } - } -} - -fn resolve_conflict_at_path( - path: &Path, - start_line: usize, - resolution: ConflictResolution, - cx: &mut Context, -) -> Result<(), String> { - let content = BufferStore::content_for_path(path, cx) - .or_else(|| std::fs::read_to_string(path).ok()) - .ok_or_else(|| format!("Could not read {}", path.display()))?; - let resolved = kosmos_git::resolve_conflict_content(&content, start_line, resolution) - .ok_or_else(|| "That conflict block no longer exists".to_string())?; - - BufferStore::write_path_content(path, resolved, cx) - .map_err(|error| format!("Could not write {}: {error}", path.display())) -} - -fn push_diff_notification(cx: &mut App, notification: Notification) { - let Some(window_handle) = cx - .active_window() - .or_else(|| cx.windows().into_iter().next()) - else { - return; - }; - - let _ = window_handle.update(cx, move |_, window, cx| { - window.push_notification(notification, cx); - }); -} - -fn diff_rows( - root: PathBuf, - rows: Rc>>, - row_heights: Rc>, - scroll_handle: VirtualListScrollHandle, - highlight_cache: Rc, - delegate: Entity, - active_file: Option, - soft_wrap: bool, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let rem_size = window.rem_size(); - let measured_width = cx.global::().wrap_width_px; - let wrap_columns = diff_wrap_columns(window, measured_width); - let item_sizes = Rc::new( - rows.iter() - .zip(row_heights.iter()) - .map(|(row, unwrapped_height)| { - let height = if soft_wrap { - diff_row_height(row.as_ref(), true, wrap_columns) - } else { - *unwrapped_height - }; - size(px(0.0), rems(height).to_pixels(rem_size)) - }) - .collect::>(), - ); - - let list = v_virtual_list(cx.entity().clone(), "diff-rows", item_sizes, { - move |_, range, _window, cx| { - range - .map(|ix| { - render_diff_row( - ix, - rows[ix].clone(), - root.clone(), - highlight_cache.clone(), - delegate.clone(), - active_file.clone(), - soft_wrap, - wrap_columns, - cx, - ) - }) - .collect::>() - } - }) - .flex_grow() - .size_full() - .track_scroll(&scroll_handle) - .with_sizing_behavior(ListSizingBehavior::Auto); - - div() - .size_full() - .min_w_0() - .min_h_0() - .child(list) - .on_children_prepainted(|bounds, _, cx| { - let Some(bounds) = bounds.first().copied() else { - return; - }; - update_diff_wrap_width(f32::from(bounds.size.width), cx); - }) - .into_any_element() -} - -fn diff_row_height(row: &DiffRow, soft_wrap: bool, wrap_columns: usize) -> f32 { - match row { - DiffRow::File { .. } => DIFF_FILE_ROW_HEIGHT_REM, - DiffRow::Unchanged { .. } => DIFF_GAP_ROW_HEIGHT_REM, - DiffRow::ConflictActions { .. } => DIFF_ROW_HEIGHT_REM, - DiffRow::ConflictMarker { text, .. } => diff_text_row_height(text, soft_wrap, wrap_columns), - DiffRow::ConflictLine { line, .. } => { - diff_text_row_height(&line.text, soft_wrap, wrap_columns) - } - DiffRow::Message { text, .. } => diff_text_row_height(text, soft_wrap, wrap_columns), - DiffRow::Line { line, .. } => diff_text_row_height(&line.text, soft_wrap, wrap_columns), - } -} - -fn diff_text_row_height(text: &str, soft_wrap: bool, wrap_columns: usize) -> f32 { - DIFF_ROW_HEIGHT_REM * diff_wrap_rows(text, soft_wrap, wrap_columns) as f32 -} - -fn diff_wrap_rows(text: &str, soft_wrap: bool, wrap_columns: usize) -> usize { - if !soft_wrap { - return 1; - } - - let limit = wrap_columns.max(1); - let mut rows = 1; - let mut width = 0; - - for ch in text.chars() { - let ch_width = if ch == '\t' { 4 } else { 1 }; - if width > 0 && width + ch_width > limit { - rows += 1; - width = 0; - } - width += ch_width; - } - - rows -} - -fn update_diff_wrap_width(width_px: f32, cx: &mut App) { - let changed = cx.update_global::(|state, _| { - let width_px = width_px.round(); - let changed = state - .wrap_width_px - .is_none_or(|current| (current - width_px).abs() >= 1.0); - if changed { - state.wrap_width_px = Some(width_px); - } - changed - }); - - if changed { - cx.refresh_windows(); - } -} - -#[derive(Clone)] -struct DiffTextSegment { - text: String, - range: Range, -} - -fn wrap_diff_text(text: &str, soft_wrap: bool, wrap_columns: usize) -> Vec { - if !soft_wrap || text.is_empty() { - return vec![DiffTextSegment { - text: text.to_string(), - range: 0..text.len(), - }]; - } - - let limit = wrap_columns.max(1); - let mut segments = Vec::new(); - let mut start = 0; - let mut width = 0; - - for (ix, ch) in text.char_indices() { - let ch_width = if ch == '\t' { 4 } else { 1 }; - if width > 0 && width + ch_width > limit { - segments.push(DiffTextSegment { - text: text[start..ix].to_string(), - range: start..ix, - }); - start = ix; - width = 0; - } - width += ch_width; - } - - segments.push(DiffTextSegment { - text: text[start..].to_string(), - range: start..text.len(), - }); - segments -} - -fn highlights_for_segment( - highlights: Option<&[LineHighlightsItem]>, - segment: &Range, -) -> LineHighlights { - let Some(highlights) = highlights else { - return Vec::new(); - }; - - highlights - .iter() - .filter_map(|(range, style)| { - let start = range.start.max(segment.start); - let end = range.end.min(segment.end); - (start < end).then(|| (start - segment.start..end - segment.start, style.clone())) - }) - .collect() -} - -fn diff_wrap_columns(window: &Window, measured_width: Option) -> usize { - let rem_size = window.rem_size(); - let reserved = - rems(CARD_MARGIN_X_REM * 2.0 + LINE_NUMBER_WIDTH_REM + SIGN_WIDTH_REM + 0.125 + 1.0) - .to_pixels(rem_size); - let width = measured_width - .map(px) - .unwrap_or_else(|| window.bounds().size.width); - let available = (width - reserved).max(px(120.0)); - let char_width = f32::from(rem_size) * DIFF_CODE_TEXT_SIZE_REM * DIFF_MONO_CHAR_WIDTH_FACTOR; - - (f32::from(available) / char_width).floor().max(20.0) as usize -} - -fn render_diff_row( - ix: usize, - row: Arc, - root: PathBuf, - highlight_cache: Rc, - delegate: Entity, - active_file: Option, - soft_wrap: bool, - wrap_columns: usize, - cx: &mut Context, -) -> AnyElement { - match row.as_ref() { - DiffRow::File { - path, - old_path, - kind, - insertions, - deletions, - binary, - } => render_file_row( - ix, - path.clone(), - old_path.clone(), - *kind, - *insertions, - *deletions, - *binary, - root, - delegate, - active_file, - cx, - ), - DiffRow::Unchanged { lines, .. } => render_unchanged_row(ix, *lines, cx), - DiffRow::Line { - path, - can_open, - line, - } => render_line_row( - ix, - path.clone(), - *can_open, - line.clone(), - root, - highlight_cache, - delegate, - soft_wrap, - wrap_columns, - cx, - ), - DiffRow::ConflictActions { - path, - start_line, - current_label, - incoming_label, - } => render_conflict_actions_row( - ix, - root, - path.clone(), - *start_line, - current_label.clone(), - incoming_label.clone(), - cx, - ), - DiffRow::ConflictMarker { - line, text, side, .. - } => { - render_conflict_marker_row(ix, *line, text.clone(), *side, soft_wrap, wrap_columns, cx) - } - DiffRow::ConflictLine { path, side, line } => render_conflict_line_row( - ix, - path.clone(), - *side, - line.clone(), - root, - highlight_cache, - delegate, - soft_wrap, - wrap_columns, - cx, - ), - DiffRow::Message { text, .. } => { - render_message_row(ix, text.clone(), soft_wrap, wrap_columns, cx) - } - } -} - -fn render_file_row( - ix: usize, - path: String, - old_path: Option, - kind: FileChangeKind, - insertions: usize, - deletions: usize, - binary: bool, - root: PathBuf, - delegate: Entity, - active_file: Option, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let display_path = old_path - .as_ref() - .filter(|old_path| old_path.as_str() != path.as_str()) - .map(|old_path| format!("{old_path} -> {path}")) - .unwrap_or_else(|| path.clone()); - let icon = icon_for_path(Path::new(&path)); - let absolute_path = root.join(&path); - let can_open = kind != FileChangeKind::Deleted && absolute_path.exists(); - let open_path = absolute_path.clone(); - let is_active = active_file.as_deref() == Some(path.as_str()); - - div() - .id(("diff-file", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_FILE_ROW_HEIGHT_REM)) - .w_full() - .flex() - .items_center() - .justify_between() - .gap_2() - .px_3() - .border_1() - .border_color(theme.border_subtle) - .bg(if is_active { - theme.bg_selected - } else { - theme.bg_elevated - }) - .hover(move |this| this.bg(theme.bg_hover)) - .when(can_open, |this| { - this.cursor_pointer().on_click(move |_, _, cx| { - let path = open_path.clone(); - delegate.update(cx, |delegate, cx| delegate.open_file(path, cx)); - }) - }) - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_2() - .child( - Icon::new(icon) - .size(14.0) - .color(file_icon_color(kind, theme)), - ) - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .text_sm() - .text_color(theme.text_emphasis) - .child(display_path), - ) - .when(kind != FileChangeKind::Modified || binary, |this| { - this.child(change_kind_tag(kind, binary, theme)) - }), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_2() - .child(diff_stats(insertions, deletions, theme)), - ) - .into_any_element() -} - -fn render_unchanged_row(ix: usize, lines: usize, cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - div() - .id(("diff-unchanged", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_GAP_ROW_HEIGHT_REM)) - .w_full() - .flex() - .items_center() - .overflow_hidden() - .bg(gpui::Hsla::from(theme.bg_hover).opacity(0.65)) - .text_color(theme.text_subtle) - .child( - div() - .h_full() - .w(rems(2.0)) - .flex_none() - .flex() - .items_center() - .justify_center() - .border_r_1() - .border_color(theme.border_subtle) - .child( - Icon::new(IconName::ChevronDown) - .size(13.0) - .color(theme.text_muted), - ), - ) - .child( - div() - .px_3() - .text_xs() - .child(format!("{lines} unmodified line{}", plural(lines))), - ) - .into_any_element() -} - -fn render_conflict_actions_row( - ix: usize, - root: PathBuf, - path: String, - start_line: usize, - current_label: String, - incoming_label: String, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .id(("diff-conflict-actions", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_ROW_HEIGHT_REM)) - .w_full() - .flex() - .items_center() - .gap_2() - .overflow_hidden() - .bg(theme.bg_hover) - .px_3() - .text_xs() - .text_color(theme.text_subtle) - .child(conflict_action_link( - "Accept current change", - root.clone(), - path.clone(), - start_line, - ConflictResolution::Current, - cx, - )) - .child(conflict_action_separator(theme)) - .child(conflict_action_link( - "Accept incoming change", - root.clone(), - path.clone(), - start_line, - ConflictResolution::Incoming, - cx, - )) - .child(conflict_action_separator(theme)) - .child(conflict_action_link( - "Accept both", - root, - path, - start_line, - ConflictResolution::Both, - cx, - )) - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .text_color(theme.text_muted) - .child(format!("{current_label} -> {incoming_label}")), - ) - .into_any_element() -} - -fn conflict_action_link( - label: &'static str, - root: PathBuf, - path: String, - start_line: usize, - resolution: ConflictResolution, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let id = format!( - "diff-conflict-action-{}-{}-{}", - conflict_resolution_key(resolution), - start_line, - path - ); - div() - .id(id) - .flex_none() - .cursor_pointer() - .text_color(theme.text_muted) - .hover(move |this| this.text_color(theme.text_emphasis)) - .on_click(cx.listener(move |_, _, _, cx| { - accept_conflict(root.clone(), path.clone(), start_line, resolution, cx); - })) - .child(label) - .into_any_element() -} - -fn conflict_resolution_key(resolution: ConflictResolution) -> &'static str { - match resolution { - ConflictResolution::Current => "current", - ConflictResolution::Incoming => "incoming", - ConflictResolution::Both => "both", - } -} - -fn conflict_action_separator(theme: theme::Theme) -> AnyElement { - div() - .flex_none() - .text_color(theme.border_strong) - .child("|") - .into_any_element() -} - -fn render_conflict_marker_row( - ix: usize, - line: usize, - text: String, - side: Option, - soft_wrap: bool, - wrap_columns: usize, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let segments = wrap_diff_text(&text, soft_wrap, wrap_columns); - let segment_count = segments.len().max(1); - let (bg, color) = match side { - Some(ConflictSide::Current) => ( - gpui::Hsla::from(theme.success).opacity(0.22), - theme.text_emphasis, - ), - Some(ConflictSide::Incoming) => ( - gpui::Hsla::from(theme.accent).opacity(0.22), - theme.text_emphasis, - ), - None => (gpui::Hsla::from(theme.bg_hover), theme.text_subtle), - }; - - div() - .id(("diff-conflict-marker", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_ROW_HEIGHT_REM * segment_count as f32)) - .w_full() - .min_w_0() - .flex() - .flex_col() - .bg(bg) - .font_family(FONT_FAMILY) - .text_size(rems(DIFF_CODE_TEXT_SIZE_REM)) - .text_color(color) - .children( - segments - .into_iter() - .enumerate() - .map(|(segment_ix, segment)| { - div() - .h(rems(DIFF_ROW_HEIGHT_REM)) - .w_full() - .min_w_0() - .flex() - .items_center() - .child(diff_line_marker(DiffLineKind::Context, theme)) - .child(line_number_cell((segment_ix == 0).then_some(line), theme)) - .child(div().w(rems(SIGN_WIDTH_REM)).flex_none()) - .child( - div() - .min_w_0() - .flex_1() - .overflow_hidden() - .whitespace_nowrap() - .child(segment.text), - ) - }), - ) - .into_any_element() -} - -fn render_conflict_line_row( - ix: usize, - path: String, - side: ConflictSide, - line: ConflictLine, - root: PathBuf, - highlight_cache: Rc, - delegate: Entity, - soft_wrap: bool, - wrap_columns: usize, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let bg = match side { - ConflictSide::Current => gpui::Hsla::from(theme.success).opacity(0.12), - ConflictSide::Incoming => gpui::Hsla::from(theme.accent).opacity(0.12), - }; - let side_color = match side { - ConflictSide::Current => theme.success, - ConflictSide::Incoming => theme.accent, - }; - let line_number = line.line; - let key = conflict_line_highlight_key(side, line_number); - let highlights = highlight_cache.highlights(&path, &key); - let segments = wrap_diff_text(&line.text, soft_wrap, wrap_columns); - let segment_count = segments.len().max(1); - let absolute_path = root.join(path); - - div() - .id(("diff-conflict-line", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_ROW_HEIGHT_REM * segment_count as f32)) - .w_full() - .min_w_0() - .flex() - .flex_col() - .bg(bg) - .font_family(FONT_FAMILY) - .text_size(rems(DIFF_CODE_TEXT_SIZE_REM)) - .text_color(theme.text) - .hover(move |this| this.bg(theme.bg_hover)) - .cursor_pointer() - .on_click(move |_, _, cx| { - let path = absolute_path.clone(); - delegate.update(cx, |delegate, cx| { - delegate.open_file_at(path, line_number, 0, cx) - }); - }) - .children( - segments - .into_iter() - .enumerate() - .map(|(segment_ix, segment)| { - let segment_highlights = highlights_for_segment(highlights, &segment.range); - div() - .h(rems(DIFF_ROW_HEIGHT_REM)) - .w_full() - .min_w_0() - .flex() - .items_center() - .child(diff_line_marker(DiffLineKind::Context, theme)) - .child(line_number_cell( - (segment_ix == 0).then_some(line_number), - theme, - )) - .child( - div() - .w(rems(SIGN_WIDTH_REM)) - .flex_none() - .text_color(side_color) - .child(if segment_ix == 0 { "|" } else { " " }), - ) - .child( - div() - .min_w_0() - .flex_1() - .overflow_hidden() - .whitespace_nowrap() - .child(highlighted_code_text( - segment.text, - Some(&segment_highlights), - )), - ) - }), - ) - .into_any_element() -} - -fn render_line_row( - ix: usize, - path: String, - can_open: bool, - line: DiffLine, - root: PathBuf, - highlight_cache: Rc, - delegate: Entity, - soft_wrap: bool, - wrap_columns: usize, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let (sign, text_color, bg) = match line.kind { - DiffLineKind::Context => (' ', theme.text, gpui::Hsla::from(theme.bg_surface)), - DiffLineKind::Added => ( - '+', - theme.text, - gpui::Hsla::from(theme.success).opacity(0.12), - ), - DiffLineKind::Removed => ( - '-', - theme.text, - gpui::Hsla::from(theme.danger).opacity(0.12), - ), - }; - let target_line = line.new_line; - let line_highlight_key = line_highlight_key(&line); - let absolute_path = root.join(&path); - let display_line = line.new_line.or(line.old_line); - let highlights = highlight_cache.highlights(&path, &line_highlight_key); - let segments = wrap_diff_text(&line.text, soft_wrap, wrap_columns); - let segment_count = segments.len().max(1); - - div() - .id(("diff-line", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_ROW_HEIGHT_REM * segment_count as f32)) - .w_full() - .min_w_0() - .flex() - .flex_col() - .bg(bg) - .font_family(FONT_FAMILY) - .text_size(rems(DIFF_CODE_TEXT_SIZE_REM)) - .text_color(text_color) - .hover(move |this| this.bg(theme.bg_hover)) - .when(can_open && target_line.is_some(), |this| { - this.cursor_pointer().on_click(move |_, _, cx| { - let Some(line) = target_line else { - return; - }; - let path = absolute_path.clone(); - delegate.update(cx, |delegate, cx| delegate.open_file_at(path, line, 0, cx)); - }) - }) - .children( - segments - .into_iter() - .enumerate() - .map(|(segment_ix, segment)| { - let segment_highlights = highlights_for_segment(highlights, &segment.range); - div() - .h(rems(DIFF_ROW_HEIGHT_REM)) - .w_full() - .min_w_0() - .flex() - .items_center() - .child(diff_line_marker(line.kind, theme)) - .child(line_number_cell( - (segment_ix == 0).then_some(display_line).flatten(), - theme, - )) - .child( - div() - .w(rems(SIGN_WIDTH_REM)) - .flex_none() - .text_color(match line.kind { - DiffLineKind::Added => theme.success, - DiffLineKind::Removed => theme.danger, - DiffLineKind::Context => theme.text_subtle, - }) - .child(if segment_ix == 0 { - sign.to_string() - } else { - " ".to_string() - }), - ) - .child( - div() - .min_w_0() - .flex_1() - .overflow_hidden() - .whitespace_nowrap() - .child(highlighted_code_text( - segment.text, - Some(&segment_highlights), - )), - ) - }), - ) - .into_any_element() -} - -fn render_message_row( - ix: usize, - text: String, - soft_wrap: bool, - wrap_columns: usize, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let segments = wrap_diff_text(&text, soft_wrap, wrap_columns); - let segment_count = segments.len().max(1); - div() - .id(("diff-message", ix)) - .mx(rems(CARD_MARGIN_X_REM)) - .h(rems(DIFF_ROW_HEIGHT_REM * segment_count as f32)) - .w_full() - .flex() - .flex_col() - .px_3() - .rounded(rems(0.375)) - .bg(gpui::Hsla::from(theme.bg_hover).opacity(0.35)) - .text_sm() - .text_color(theme.text_subtle) - .overflow_hidden() - .children(segments.into_iter().map(|segment| { - div() - .h(rems(DIFF_ROW_HEIGHT_REM)) - .w_full() - .flex() - .items_center() - .overflow_hidden() - .whitespace_nowrap() - .child(segment.text) - })) - .into_any_element() -} - -fn line_number_cell(line: Option, theme: theme::Theme) -> AnyElement { - div() - .w(rems(LINE_NUMBER_WIDTH_REM)) - .h_full() - .flex_none() - .flex() - .items_center() - .justify_end() - .pr_2() - .text_color(theme.text_subtle) - .child(line.map(|line| line.to_string()).unwrap_or_default()) - .into_any_element() -} - -fn diff_line_marker(kind: DiffLineKind, theme: theme::Theme) -> AnyElement { - let color = match kind { - DiffLineKind::Added => gpui::Hsla::from(theme.success), - DiffLineKind::Removed => gpui::Hsla::from(theme.danger), - DiffLineKind::Context => gpui::Hsla::from(theme.bg_surface), - }; - div() - .h_full() - .w(rems(0.125)) - .flex_none() - .bg(color) - .into_any_element() -} - -fn centered_state( - message: impl Into, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .text_color(theme.text_subtle) - .child(Icon::new(IconName::Diff).size(28.0).color(theme.text_muted)) - .child(div().text_sm().child(message.into())) - .into_any_element() -} - -fn flatten_file_diff(file: &kosmos_git::FileDiff) -> Vec> { - let mut rows = Vec::new(); - let (insertions, deletions) = file_stats(file); - rows.push(Arc::new(DiffRow::File { - path: file.path.clone(), - old_path: file.old_path.clone(), - kind: file.kind, - insertions, - deletions, - binary: file.binary, - })); - - if file.binary { - rows.push(Arc::new(DiffRow::Message { - path: file.path.clone(), - text: "Binary file changed".to_string(), - })); - return rows; - } - if !file.conflicts.is_empty() { - for conflict in &file.conflicts { - push_conflict_rows(&mut rows, &file.path, conflict); - } - return rows; - } - if file.hunks.is_empty() { - rows.push(Arc::new(DiffRow::Message { - path: file.path.clone(), - text: "No textual diff".to_string(), - })); - return rows; - } - - let can_open = file.kind != FileChangeKind::Deleted; - let mut last_new_end = 0; - for hunk in &file.hunks { - let unchanged_lines = hunk.new_start.saturating_sub(last_new_end + 1); - if unchanged_lines > 0 { - rows.push(Arc::new(DiffRow::Unchanged { - path: file.path.clone(), - lines: unchanged_lines, - })); - } - rows.extend(hunk.lines.iter().cloned().map(|line| { - Arc::new(DiffRow::Line { - path: file.path.clone(), - can_open, - line, - }) - })); - last_new_end = hunk.new_start + hunk.new_lines.saturating_sub(1); - } - rows -} - -fn push_conflict_rows(rows: &mut Vec>, path: &str, conflict: &ConflictBlock) { - rows.push(Arc::new(DiffRow::ConflictActions { - path: path.to_string(), - start_line: conflict.start_line, - current_label: conflict.current_label.clone(), - incoming_label: conflict.incoming_label.clone(), - })); - rows.push(Arc::new(DiffRow::ConflictMarker { - path: path.to_string(), - line: conflict.start_line, - text: format!("<<<<<<< {} (Current Change)", conflict.current_label), - side: Some(ConflictSide::Current), - })); - rows.extend(conflict.current.iter().cloned().map(|line| { - Arc::new(DiffRow::ConflictLine { - path: path.to_string(), - side: ConflictSide::Current, - line, - }) - })); - rows.push(Arc::new(DiffRow::ConflictMarker { - path: path.to_string(), - line: conflict.separator_line, - text: "=======".to_string(), - side: None, - })); - rows.extend(conflict.incoming.iter().cloned().map(|line| { - Arc::new(DiffRow::ConflictLine { - path: path.to_string(), - side: ConflictSide::Incoming, - line, - }) - })); - rows.push(Arc::new(DiffRow::ConflictMarker { - path: path.to_string(), - line: conflict.end_line, - text: format!(">>>>>>> {} (Incoming Change)", conflict.incoming_label), - side: Some(ConflictSide::Incoming), - })); -} - -impl DiffRow { - fn path(&self) -> &str { - match self { - Self::File { path, .. } - | Self::Unchanged { path, .. } - | Self::Line { path, .. } - | Self::ConflictActions { path, .. } - | Self::ConflictMarker { path, .. } - | Self::ConflictLine { path, .. } - | Self::Message { path, .. } => path, - } - } -} - -fn file_stats(file: &kosmos_git::FileDiff) -> (usize, usize) { - file.hunks - .iter() - .flat_map(|hunk| &hunk.lines) - .fold((0, 0), |(insertions, deletions), line| match line.kind { - DiffLineKind::Added => (insertions + 1, deletions), - DiffLineKind::Removed => (insertions, deletions + 1), - DiffLineKind::Context => (insertions, deletions), - }) -} - -fn diff_stats(insertions: usize, deletions: usize, theme: theme::Theme) -> AnyElement { - div() - .flex() - .flex_none() - .items_center() - .gap_1() - .child( - div() - .text_xs() - .text_color(theme.success) - .child(format!("+{insertions}")), - ) - .child( - div() - .text_xs() - .text_color(theme.danger) - .child(format!("-{deletions}")), - ) - .into_any_element() -} - -fn change_kind_tag(kind: FileChangeKind, binary: bool, theme: theme::Theme) -> AnyElement { - let label = if binary { - "Binary" - } else { - match kind { - FileChangeKind::Created => "Added", - FileChangeKind::Modified => "Modified", - FileChangeKind::Deleted => "Deleted", - FileChangeKind::Renamed => "Renamed", - FileChangeKind::Conflicted => "Conflict", - } - }; - div() - .flex_none() - .rounded(rems(0.25)) - .border_1() - .border_color(theme.border_subtle) - .px_1p5() - .py_0p5() - .text_xs() - .text_color(theme.text_subtle) - .child(label) - .into_any_element() -} - -fn file_icon_color(kind: FileChangeKind, theme: theme::Theme) -> gpui::Rgba { - match kind { - FileChangeKind::Created => theme.success, - FileChangeKind::Deleted => theme.danger, - FileChangeKind::Renamed | FileChangeKind::Conflicted => theme.accent_secondary, - FileChangeKind::Modified => theme.text_muted, - } -} - -fn icon_for_path(path: &Path) -> IconName { - if let Some(name) = path.file_name().and_then(|name| name.to_str()) - && let Some(icon) = IconName::for_file_name(name) - { - return icon; - } - - language::from_path(path) - .and_then(|id| IconName::for_language(id.as_str())) - .unwrap_or(IconName::File) -} - -fn plural(count: usize) -> &'static str { - if count == 1 { "" } else { "s" } -} diff --git a/crates/ui/src/tabs/file_editor/mod.rs b/crates/ui/src/tabs/file_editor/mod.rs deleted file mode 100644 index 24373be0..00000000 --- a/crates/ui/src/tabs/file_editor/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -include!("parts/actions_and_elements.rs"); -include!("parts/color_support.rs"); -include!("parts/render.rs"); -include!("parts/tests.rs"); - -pub(super) fn drop_tab(tab_id: usize, cx: &mut gpui::App) { - ComponentEditorStore::drop_tab(tab_id, cx); -} - -pub(super) fn request_reveal( - path: std::path::PathBuf, - line: usize, - column: usize, - cx: &mut gpui::App, -) { - ComponentEditorStore::request_reveal(path, line, column, cx); -} diff --git a/crates/ui/src/tabs/file_editor/parts/actions_and_elements.rs b/crates/ui/src/tabs/file_editor/parts/actions_and_elements.rs deleted file mode 100644 index c7aed79d..00000000 --- a/crates/ui/src/tabs/file_editor/parts/actions_and_elements.rs +++ /dev/null @@ -1,2355 +0,0 @@ -use std::collections::HashMap; -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::time::Duration; - -use gpui::{ - AnyElement, App, AppContext, Bounds, ClipboardItem, Context, DragMoveEvent, Empty, Entity, - EntityId, EntityInputHandler, Focusable, Global, Half, Hsla, InteractiveElement as _, - IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, - SharedString, StatefulInteractiveElement as _, Subscription, Task, Window, div, - linear_color_stop, linear_gradient, prelude::*, px, relative, rems, -}; -use gpui_component::ElementExt; -use gpui_component::highlighter::Diagnostic as ComponentDiagnostic; -use gpui_component::input::{ - DocumentColorProvider, HoverProvider, Input, InputEvent, InputState, Rope, RopeExt, TabSize, -}; -use lsp_types::{ - Color, ColorInformation, CompletionItem, CompletionItemKind, CompletionResponse, - CompletionTextEdit, Diagnostic as LspDiagnostic, Documentation, Position as LspPosition, -}; - -use file_editor::{Buffer, BufferId, BufferStore, soft_wrap_enabled}; -use file_tree::ActiveFileTree; -use icons::{Icon, IconName}; -use tabs::{Tab, registry}; -use theme::{ActiveTheme, Theme}; - -const COMPONENT_INPUT_KEY_CONTEXT: &str = "Input"; -const FONT_FAMILY: &str = "DejaVu Sans Mono"; -const FONT_SIZE_REM: f32 = 0.875; -const TAB_SIZE_COLUMNS: usize = 4; -const COMPLETION_MENU_MAX_ITEMS: usize = 50; -const COMPLETION_MENU_VISIBLE_ITEMS: usize = 11; -const COMPLETION_MENU_MIN_WIDTH: Pixels = px(180.0); -const COMPLETION_MENU_MAX_WIDTH: Pixels = px(520.0); -const COMPLETION_MENU_CHAR_WIDTH: f32 = 7.0; -const COMPLETION_CURSOR_CHAR_WIDTH_FACTOR: f32 = 0.62; -const COMPLETION_MENU_HORIZONTAL_PADDING: Pixels = px(48.0); -const MAX_COMPLETION_DETAIL_CHARS: usize = 56; -const COLOR_PICKER_WIDTH: Pixels = px(380.0); -const COLOR_PICKER_SPECTRUM_HEIGHT: Pixels = px(254.0); -const COLOR_PICKER_SLIDER_HEIGHT: Pixels = px(12.0); -const COLOR_PICKER_THUMB_SIZE: Pixels = px(16.0); -const COLOR_PICKER_SLIDER_THUMB_WIDTH: Pixels = px(10.0); -const COLOR_PICKER_SLIDER_THUMB_HEIGHT: Pixels = px(18.0); -const COLOR_SWATCH_SIZE: Pixels = px(12.0); -const COLOR_SWATCH_GAP: Pixels = px(4.0); -const DIAGNOSTIC_REQUEST_DEBOUNCE: Duration = Duration::from_millis(250); -// rust-analyzer can publish cargo-check diagnostics well after didChange. -const DIAGNOSTIC_FOLLOW_UP_DELAY: Duration = Duration::from_millis(100); -const DIAGNOSTIC_FOLLOW_UP_REQUESTS: usize = 30; - -#[derive(Default)] -struct ComponentEditorStore { - inputs: HashMap, - pending_reveals: HashMap, -} - -#[derive(Clone, Copy)] -struct EditorReveal { - line: usize, - column: usize, -} - -struct ComponentEditorInput { - input: Entity, - completions: Entity, - color_picker: Entity, - diagnostics: Entity, - _buffer_observer: Subscription, - _input_observer: Subscription, - buffer_id: BufferId, - soft_wrap: bool, - root: Option, -} - -#[derive(Clone)] -struct ComponentEditorEntities { - input: Entity, - completions: Entity, - color_picker: Entity, -} - -#[derive(Clone)] -struct ComponentLspProvider { - buffer: Entity, - root: Option, -} - -#[derive(Clone)] -struct ComponentColorProvider { - buffer: Entity, -} - -struct ComponentCompletionMenu { - input: Entity, - buffer: Entity, - root: Option, - items: Vec, - replace_range: Range, - width: Option, - selected: usize, - first_visible: usize, - request_id: u64, - suppress_next_request: bool, -} - -struct ComponentDiagnostics { - input: Entity, - buffer: Entity, - root: Option, - request_id: u64, - last_epoch: u64, - buffer_was_dirty: bool, -} - -struct ComponentColorPicker { - input: Entity, - buffer: Entity, - active: Option, - active_hue: Option, - active_saturation: Option, - last_scroll_offset: Option>, - picker_anchor: Option<(Pixels, Pixels)>, - overlay_bounds: Option>, - spectrum_bounds: Option>, - hue_bounds: Option>, - alpha_bounds: Option>, -} - -#[derive(Clone, Copy, Eq, PartialEq)] -enum ComponentColorDragKind { - Spectrum, - Hue, - Alpha, -} - -#[derive(Clone)] -struct ComponentColorDrag { - entity_id: EntityId, - kind: ComponentColorDragKind, -} - -#[derive(Clone, Copy)] -struct ComponentColorHsva { - h: f32, - s: f32, - v: f32, - a: f32, -} - -impl Render for ComponentColorDrag { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } -} - -#[derive(Clone)] -struct ComponentCompletionItem { - item: CompletionItem, - label: String, - detail: Option, -} - -impl ComponentEditorStore { - fn state_for_tab( - tab_id: usize, - buffer: &Entity, - root: Option, - window: &mut Window, - cx: &mut App, - ) -> ComponentEditorEntities { - if cx.try_global::().is_none() { - cx.set_global(Self::default()); - } - - let buffer_id = buffer.read(cx).id(); - let soft_wrap = soft_wrap_enabled(cx); - - if let Some(( - input, - completions, - color_picker, - diagnostics, - previous_soft_wrap, - previous_root, - )) = cx - .global::() - .inputs - .get(&tab_id) - .filter(|existing| existing.buffer_id == buffer_id) - .map(|existing| { - ( - existing.input.clone(), - existing.completions.clone(), - existing.color_picker.clone(), - existing.diagnostics.clone(), - existing.soft_wrap, - existing.root.clone(), - ) - }) - { - Self::sync_soft_wrap(tab_id, &input, previous_soft_wrap, soft_wrap, window, cx); - Self::sync_lsp_state( - tab_id, - &input, - &completions, - &diagnostics, - buffer, - previous_root, - root, - cx, - ); - if Self::sync_from_buffer(&input, buffer, window, cx) { - ComponentDiagnostics::request(&diagnostics, cx); - } - return ComponentEditorEntities { - input, - completions, - color_picker, - }; - } - - let (initial, language) = { - let buffer = buffer.read(cx); - ( - buffer.content().to_string(), - buffer - .language() - .map(|language| component_editor_language(language.as_str()).to_string()) - .unwrap_or_else(|| "plaintext".to_string()), - ) - }; - - let lsp_provider = Rc::new(ComponentLspProvider { - buffer: buffer.clone(), - root: root.clone(), - }); - let color_provider = Rc::new(ComponentColorProvider { - buffer: buffer.clone(), - }); - let input = cx.new(|cx| { - let mut input = InputState::new(window, cx) - .code_editor(language) - .multi_line(true) - .soft_wrap(soft_wrap) - .tab_size(TabSize { - tab_size: TAB_SIZE_COLUMNS, - ..Default::default() - }) - .default_value(initial); - input.lsp.hover_provider = Some(lsp_provider); - input.lsp.document_color_provider = Some(color_provider); - input - }); - let completions = - cx.new(|_| ComponentCompletionMenu::new(input.clone(), buffer.clone(), root.clone())); - let color_picker = - cx.new(|cx| ComponentColorPicker::new(input.clone(), buffer.clone(), window, cx)); - let buffer_was_dirty = buffer.read(cx).is_dirty(); - let diagnostics = cx.new(|_| { - ComponentDiagnostics::new( - input.clone(), - buffer.clone(), - root.clone(), - buffer_was_dirty, - ) - }); - let diagnostics_for_buffer = diagnostics.clone(); - let buffer_observer = cx.observe(buffer, move |_, cx| { - diagnostics_for_buffer - .update(cx, |diagnostics, cx| diagnostics.handle_buffer_change(cx)); - }); - let color_picker_for_input = color_picker.clone(); - let input_observer = cx.observe(&input, move |_, cx| { - color_picker_for_input.update(cx, |picker, cx| picker.handle_input_update(cx)); - }); - - let input_for_sub = input.clone(); - let buffer_for_sub = buffer.clone(); - let completions_for_sub = completions.clone(); - let diagnostics_for_sub = diagnostics.clone(); - cx.subscribe(&input, move |_, event: &InputEvent, cx| { - if !matches!(event, InputEvent::Change) { - return; - } - let value = input_for_sub.read(cx).value().to_string(); - buffer_for_sub.update(cx, |buffer, cx| { - let current_len = buffer.content().len(); - if buffer.content() != value { - buffer.replace_range(0..current_len, &value, cx); - } - }); - ComponentCompletionMenu::request(&completions_for_sub, cx); - ComponentDiagnostics::request(&diagnostics_for_sub, cx); - }) - .detach(); - - cx.update_global::(|store, _| { - store.inputs.insert( - tab_id, - ComponentEditorInput { - input: input.clone(), - completions: completions.clone(), - color_picker: color_picker.clone(), - diagnostics: diagnostics.clone(), - _buffer_observer: buffer_observer, - _input_observer: input_observer, - buffer_id, - soft_wrap, - root, - }, - ); - }); - - ComponentDiagnostics::request(&diagnostics, cx); - - ComponentEditorEntities { - input, - completions, - color_picker, - } - } - - fn drop_tab(tab_id: usize, cx: &mut App) { - if cx.try_global::().is_none() { - return; - } - cx.update_global::(|store, _| { - store.inputs.remove(&tab_id); - }); - } - - fn request_reveal(path: PathBuf, line: usize, column: usize, cx: &mut App) { - if cx.try_global::().is_none() { - cx.set_global(Self::default()); - } - cx.update_global::(|store, _| { - store - .pending_reveals - .insert(path, EditorReveal { line, column }); - }); - } - - fn take_pending_reveal(path: &Path, cx: &mut App) -> Option { - cx.try_global::()?; - cx.update_global::(|store, _| store.pending_reveals.remove(path)) - } - - fn sync_soft_wrap( - tab_id: usize, - input: &Entity, - previous: bool, - current: bool, - window: &mut Window, - cx: &mut App, - ) { - if previous == current { - return; - } - - input.update(cx, |input, cx| input.set_soft_wrap(current, window, cx)); - cx.update_global::(|store, _| { - if let Some(existing) = store.inputs.get_mut(&tab_id) { - existing.soft_wrap = current; - } - }); - } - - fn sync_lsp_state( - tab_id: usize, - input: &Entity, - completions: &Entity, - diagnostics: &Entity, - buffer: &Entity, - previous_root: Option, - current_root: Option, - cx: &mut App, - ) { - if previous_root == current_root { - return; - } - - let lsp_provider = Rc::new(ComponentLspProvider { - buffer: buffer.clone(), - root: current_root.clone(), - }); - input.update(cx, |input, _| { - input.lsp.hover_provider = Some(lsp_provider); - }); - completions.update(cx, |menu, cx| { - menu.buffer = buffer.clone(); - menu.root = current_root.clone(); - menu.clear(cx); - }); - diagnostics.update(cx, |diagnostics, cx| { - diagnostics.buffer = buffer.clone(); - diagnostics.root = current_root.clone(); - diagnostics.clear(cx); - }); - cx.update_global::(|store, _| { - if let Some(existing) = store.inputs.get_mut(&tab_id) { - existing.root = current_root; - } - }); - ComponentDiagnostics::request(diagnostics, cx); - } - - fn sync_from_buffer( - input: &Entity, - buffer: &Entity, - window: &mut Window, - cx: &mut App, - ) -> bool { - let buffer_text = buffer.read(cx).content().to_string(); - let input_state = input.read(cx); - if input_state.has_marked_text() { - return false; - } - - if input_state.value().as_ref() != buffer_text.as_str() { - input.update(cx, |input, cx| input.set_value(buffer_text, window, cx)); - return true; - } - false - } -} - -impl Global for ComponentEditorStore {} - -impl ComponentCompletionMenu { - fn new(input: Entity, buffer: Entity, root: Option) -> Self { - Self { - input, - buffer, - root, - items: Vec::new(), - replace_range: 0..0, - width: None, - selected: 0, - first_visible: 0, - request_id: 0, - suppress_next_request: false, - } - } - - fn request(menu: &Entity, cx: &mut App) { - let pending = menu.update(cx, |menu, cx| { - if menu.suppress_next_request { - menu.suppress_next_request = false; - menu.clear(cx); - return None; - } - - menu.request_id += 1; - let request_id = menu.request_id; - - let content = menu.input.read(cx).value().to_string(); - let offset = menu.input.read(cx).cursor(); - let raw_query = completion_raw_query_for_offset(&content, offset); - if !completion_should_request(&raw_query) { - menu.clear(cx); - return None; - } - - let query = completion_filter_query(&raw_query); - let replace_range = offset.saturating_sub(query.len())..offset; - menu.replace_range = replace_range.clone(); - let position = Rope::from(content.as_str()).offset_to_position(offset); - let request = { - let buffer = menu.buffer.read(cx); - let Some(language) = buffer.language() else { - menu.clear(cx); - return None; - }; - let language_id = language.as_str().to_string(); - if !lsp::has_installed_server(&language_id) { - menu.clear(cx); - return None; - } - let path = buffer.path().to_path_buf(); - let Some(root) = menu - .root - .clone() - .or_else(|| path.parent().map(Path::to_path_buf)) - else { - menu.clear(cx); - return None; - }; - - lsp::CompletionRequest { - root, - path, - language_id, - content, - position: lsp::Position { - line: position.line, - character: position.character, - }, - } - }; - - Some((request_id, request, query, replace_range)) - }); - - let Some((request_id, request, query, replace_range)) = pending else { - return; - }; - - let menu = menu.clone(); - cx.spawn(async move |cx| { - let response = cx - .background_executor() - .spawn(async move { lsp::completion(request) }) - .await; - let _ = menu.update(cx, |menu, cx| { - if menu.request_id != request_id { - return; - } - - let mut response = match response { - Ok(Some(response)) => response, - Ok(None) | Err(_) => empty_completion_response(), - }; - enhance_completion_response(&mut response); - let response = rank_completion_response(response, &query); - menu.set_response(response, replace_range, cx); - }); - }) - .detach(); - } - - fn set_response( - &mut self, - response: CompletionResponse, - replace_range: Range, - cx: &mut Context, - ) { - self.items = completion_response_items(response) - .into_iter() - .take(COMPLETION_MENU_MAX_ITEMS) - .map(ComponentCompletionItem::new) - .collect(); - self.replace_range = replace_range; - if self.items.is_empty() { - self.width = None; - } else { - let desired_width = completion_menu_desired_width(&self.items); - self.width = Some( - self.width - .map_or(desired_width, |width| width.max(desired_width)), - ); - } - self.selected = 0; - self.first_visible = 0; - cx.notify(); - } - - fn clear(&mut self, cx: &mut Context) { - self.clear_without_notify(); - cx.notify(); - } - - fn clear_without_notify(&mut self) { - self.request_id += 1; - self.items.clear(); - self.width = None; - self.selected = 0; - self.first_visible = 0; - } - - fn is_open(&self) -> bool { - !self.items.is_empty() - } - - fn select_previous(&mut self, cx: &mut Context) -> bool { - if !self.is_open() { - return false; - } - self.selected = self.selected.saturating_sub(1); - self.keep_selected_visible(); - cx.notify(); - true - } - - fn select_next(&mut self, cx: &mut Context) -> bool { - if !self.is_open() { - return false; - } - self.selected = (self.selected + 1).min(self.items.len().saturating_sub(1)); - self.keep_selected_visible(); - cx.notify(); - true - } - - fn scroll(&mut self, lines: i32, cx: &mut Context) -> bool { - if !self.is_open() || self.items.len() <= COMPLETION_MENU_VISIBLE_ITEMS || lines == 0 { - return false; - } - - let max_first = self - .items - .len() - .saturating_sub(COMPLETION_MENU_VISIBLE_ITEMS); - self.first_visible = - (self.first_visible as i32 + lines).clamp(0, max_first as i32) as usize; - self.selected = self - .selected - .clamp(self.first_visible, self.last_visible().saturating_sub(1)); - cx.notify(); - true - } - - fn keep_selected_visible(&mut self) { - if self.selected < self.first_visible { - self.first_visible = self.selected; - } else if self.selected >= self.last_visible() { - self.first_visible = self.selected + 1 - COMPLETION_MENU_VISIBLE_ITEMS; - } - } - - fn last_visible(&self) -> usize { - (self.first_visible + COMPLETION_MENU_VISIBLE_ITEMS).min(self.items.len()) - } - - fn hide(&mut self, cx: &mut Context) -> bool { - if !self.is_open() { - return false; - } - self.clear(cx); - true - } - - fn accept_selected(&mut self, window: &mut Window, cx: &mut Context) -> bool { - let Some(entry) = self.items.get(self.selected).cloned() else { - return false; - }; - self.suppress_next_request = true; - self.apply_completion(&entry.item, window, cx); - self.clear(cx); - true - } - - fn accept_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) -> bool { - if index >= self.items.len() { - return false; - } - self.selected = index; - self.keep_selected_visible(); - self.accept_selected(window, cx) - } - - fn apply_completion(&self, item: &CompletionItem, window: &mut Window, cx: &mut Context) { - let content = self.input.read(cx).value().to_string(); - let (range, new_text) = - completion_edit_for_item(item, &content, self.replace_range.clone()); - let utf16_range = component_range_to_utf16(&content, &range); - self.input.update(cx, |input, cx| { - EntityInputHandler::replace_text_in_range( - input, - Some(utf16_range), - &new_text, - window, - cx, - ); - }); - } -} - -impl ComponentDiagnostics { - fn new( - input: Entity, - buffer: Entity, - root: Option, - buffer_was_dirty: bool, - ) -> Self { - Self { - input, - buffer, - root, - request_id: 0, - last_epoch: 0, - buffer_was_dirty, - } - } - - fn handle_buffer_change(&mut self, cx: &mut Context) { - let is_dirty = self.buffer.read(cx).is_dirty(); - if is_dirty { - self.buffer_was_dirty = true; - return; - } - - if self.buffer_was_dirty { - self.buffer_was_dirty = false; - self.did_save(cx); - } - } - - fn did_save(&mut self, cx: &mut Context) { - let content = self.input.read(cx).value().to_string(); - let Some(request) = self.save_request_for_content(content, cx) else { - return; - }; - let previous_epoch = self.last_epoch; - let entity = cx.entity().clone(); - - cx.spawn(async move |_, cx| { - let _ = cx - .background_executor() - .spawn(async move { lsp::did_save(request) }) - .await; - let _ = cx.update(|cx| { - Self::request_after( - &entity, - cx, - Duration::ZERO, - Some(previous_epoch), - DIAGNOSTIC_FOLLOW_UP_REQUESTS, - false, - ); - }); - }) - .detach(); - } - - fn request(diagnostics: &Entity, cx: &mut App) { - Self::request_after( - diagnostics, - cx, - DIAGNOSTIC_REQUEST_DEBOUNCE, - None, - DIAGNOSTIC_FOLLOW_UP_REQUESTS, - true, - ); - } - - fn request_after( - diagnostics: &Entity, - cx: &mut App, - delay: Duration, - previous_epoch: Option, - follow_up_requests: usize, - clear_stale: bool, - ) { - let pending = diagnostics.update(cx, |diagnostics, cx| { - diagnostics.request_id += 1; - let request_id = diagnostics.request_id; - let content = diagnostics.input.read(cx).value().to_string(); - if clear_stale { - diagnostics.clear_input(cx); - } - let request = diagnostics.request_for_content(content.clone(), previous_epoch, cx)?; - - Some((request_id, content, request)) - }); - - let Some((request_id, content, request)) = pending else { - return; - }; - - let diagnostics = diagnostics.clone(); - cx.spawn(async move |cx| { - cx.background_executor().timer(delay).await; - - let should_request = diagnostics.update(cx, |diagnostics, cx| { - diagnostics.request_id == request_id - && diagnostics.input.read(cx).value().as_ref() == content.as_str() - }); - if !should_request { - return; - } - - let response = cx - .background_executor() - .spawn(async move { lsp::diagnostics(request) }) - .await; - - let response_epoch = diagnostics.update(cx, |diagnostics, cx| { - if diagnostics.request_id != request_id { - return None; - } - if diagnostics.input.read(cx).value().as_ref() != content.as_str() { - return None; - } - - match response { - Ok(response) => { - let epoch = response.epoch; - if response.fresh { - diagnostics.apply(content.as_str(), response.diagnostics, epoch, cx); - } else if previous_epoch.is_none() { - diagnostics.clear_input(cx); - } - Some(epoch) - } - Err(_) => { - if previous_epoch.is_none() { - diagnostics.clear_input(cx); - } - None - } - } - }); - - let next_previous_epoch = response_epoch.or(previous_epoch); - if follow_up_requests > 0 && next_previous_epoch.is_some() { - let diagnostics = diagnostics.clone(); - let _ = cx.update(|cx| { - Self::request_after( - &diagnostics, - cx, - DIAGNOSTIC_FOLLOW_UP_DELAY, - next_previous_epoch, - follow_up_requests - 1, - false, - ); - }); - } - }) - .detach(); - } - - fn request_for_content( - &mut self, - content: String, - previous_epoch: Option, - cx: &mut Context, - ) -> Option { - let buffer = self.buffer.read(cx); - let Some(language) = buffer.language() else { - self.clear(cx); - return None; - }; - let language_id = language.as_str().to_string(); - if !lsp::has_installed_server(&language_id) { - self.clear(cx); - return None; - } - - let path = buffer.path().to_path_buf(); - let Some(root) = self - .root - .clone() - .or_else(|| path.parent().map(Path::to_path_buf)) - else { - self.clear(cx); - return None; - }; - - Some(lsp::DiagnosticsRequest { - root, - path, - language_id, - content, - previous_epoch, - }) - } - - fn save_request_for_content( - &mut self, - content: String, - cx: &mut Context, - ) -> Option { - let buffer = self.buffer.read(cx); - let Some(language) = buffer.language() else { - return None; - }; - let language_id = language.as_str().to_string(); - if !lsp::has_installed_server(&language_id) { - return None; - } - - let path = buffer.path().to_path_buf(); - let Some(root) = self - .root - .clone() - .or_else(|| path.parent().map(Path::to_path_buf)) - else { - return None; - }; - - Some(lsp::SaveRequest { - root, - path, - language_id, - content, - }) - } - - fn apply( - &mut self, - content: &str, - diagnostics: Vec, - epoch: u64, - cx: &mut Context, - ) { - self.last_epoch = epoch; - let diagnostics = component_diagnostics_from_lsp(diagnostics, content); - self.input.update(cx, |input, cx| { - if let Some(set) = input.diagnostics_mut() { - set.clear(); - set.extend(diagnostics); - } - cx.notify(); - }); - } - - fn clear(&mut self, cx: &mut Context) { - self.request_id += 1; - self.clear_input(cx); - } - - fn clear_input(&self, cx: &mut Context) { - self.input.update(cx, |input, cx| { - if let Some(set) = input.diagnostics_mut() { - if !set.is_empty() { - set.clear(); - cx.notify(); - } - } - }); - } -} - -impl ComponentColorPicker { - fn new( - input: Entity, - buffer: Entity, - _window: &mut Window, - _cx: &mut Context, - ) -> Self { - Self { - input, - buffer, - active: None, - active_hue: None, - active_saturation: None, - last_scroll_offset: None, - picker_anchor: None, - overlay_bounds: None, - spectrum_bounds: None, - hue_bounds: None, - alpha_bounds: None, - } - } - - fn open_color(&mut self, color: ComponentCssColor, cx: &mut Context) { - self.last_scroll_offset = Some(self.input.read(cx).scroll_offset()); - self.picker_anchor = self.picker_anchor_for_range(&color.range, cx); - let hsva = component_color_to_hsva(color.color); - self.active_hue = Some(hsva.h); - self.active_saturation = Some(hsva.s); - self.active = Some(color); - cx.notify(); - } - - fn picker_anchor_for_range( - &self, - range: &Range, - cx: &mut Context, - ) -> Option<(Pixels, Pixels)> { - let input = self.input.read(cx); - let bounds = input.range_to_bounds(range)?; - let origin = self.overlay_bounds?.origin; - Some(( - (bounds.origin.x - origin.x).max(px(0.0)), - (bounds.origin.y - origin.y + bounds.size.height + px(2.0)).max(px(0.0)), - )) - } - - fn handle_input_update(&mut self, cx: &mut Context) { - let scroll_offset = self.input.read(cx).scroll_offset(); - let scrolled = self - .last_scroll_offset - .is_some_and(|last_scroll_offset| last_scroll_offset != scroll_offset); - self.last_scroll_offset = Some(scroll_offset); - - if scrolled { - self.close(cx); - } - } - - fn close(&mut self, cx: &mut Context) { - if self.active.is_some() { - self.active = None; - self.active_hue = None; - self.active_saturation = None; - self.picker_anchor = None; - cx.notify(); - } - } - - fn apply_from_position( - &mut self, - kind: ComponentColorDragKind, - position: Point, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active) = self.active.as_ref() else { - return; - }; - let mut color = component_color_to_hsva(active.color); - if let Some(hue) = self.active_hue { - color.h = hue; - } - if let Some(saturation) = self.active_saturation { - color.s = saturation; - } - - match kind { - ComponentColorDragKind::Spectrum => { - let Some(bounds) = self.spectrum_bounds else { - return; - }; - color.s = component_color_unit((position.x - bounds.left()) / bounds.size.width); - color.v = - component_color_unit(1.0 - (position.y - bounds.top()) / bounds.size.height); - self.active_saturation = Some(color.s); - } - ComponentColorDragKind::Hue => { - let Some(bounds) = self.hue_bounds else { - return; - }; - color.h = component_color_unit((position.x - bounds.left()) / bounds.size.width); - self.active_hue = Some(color.h); - } - ComponentColorDragKind::Alpha => { - let Some(bounds) = self.alpha_bounds else { - return; - }; - color.a = component_color_unit((position.x - bounds.left()) / bounds.size.width); - } - } - - self.active_hue = Some(color.h); - self.active_saturation = Some(color.s); - self.apply_color(component_color_from_hsva(color), window, cx); - } - - fn apply_color(&mut self, color: Hsla, window: &mut Window, cx: &mut Context) { - let Some(active) = self.active.clone() else { - return; - }; - let content = self.input.read(cx).value().to_string(); - let Some(current) = component_css_color_for_range(&content, &active.range) else { - return; - }; - - let new_text = component_css_format_color(¤t.format, color); - let mut new_content = content.clone(); - new_content.replace_range(current.range.clone(), &new_text); - let new_rope = Rope::from(new_content.as_str()); - let document_colors = component_document_colors(&new_rope, &new_content); - let utf16_range = component_range_to_utf16(&content, ¤t.range); - self.input.update(cx, |input, cx| { - EntityInputHandler::replace_text_in_range( - input, - Some(utf16_range), - &new_text, - window, - cx, - ); - input.lsp.set_document_colors(document_colors, cx); - }); - - self.active = Some(ComponentCssColor { - range: current.range.start..current.range.start + new_text.len(), - color, - format: current.format, - }); - cx.notify(); - } - - fn render_swatches(&self, cx: &mut Context) -> Vec { - if !component_color_language(self.buffer.read(cx).language()) { - return Vec::new(); - } - - let input = self.input.read(cx); - let content = input.value().to_string(); - let Some(overlay_bounds) = self.overlay_bounds else { - return Vec::new(); - }; - let origin = overlay_bounds.origin; - - component_css_colors(&content) - .into_iter() - .filter_map(|color| { - let bounds = input.range_to_bounds(&color.range)?; - let left = (bounds.origin.x - origin.x - COLOR_SWATCH_SIZE - COLOR_SWATCH_GAP) - .max(px(0.0)); - let top = (bounds.origin.y - origin.y - + (bounds.size.height - COLOR_SWATCH_SIZE).half()) - .max(px(0.0)); - - Some( - div() - .id(("color-swatch", color.range.start)) - .absolute() - .left(left) - .top(top) - .size(COLOR_SWATCH_SIZE) - .rounded(px(1.0)) - .occlude() - .on_mouse_down( - MouseButton::Left, - cx.listener(move |picker, _, _, cx| { - picker.open_color(color.clone(), cx); - cx.stop_propagation(); - }), - ) - .into_any_element(), - ) - }) - .collect() - } - - fn render_spectrum( - &self, - color: ComponentColorHsva, - entity_id: EntityId, - cx: &mut Context, - ) -> AnyElement { - let hue = component_color_from_hsva(ComponentColorHsva { - s: 1.0, - v: 1.0, - a: 1.0, - ..color - }); - let white = gpui::hsla(0.0, 0.0, 1.0, 1.0); - let transparent_white = gpui::hsla(0.0, 0.0, 1.0, 0.0); - let black = gpui::hsla(0.0, 0.0, 0.0, 1.0); - let transparent_black = gpui::hsla(0.0, 0.0, 0.0, 0.0); - let control = ComponentColorDrag { - entity_id, - kind: ComponentColorDragKind::Spectrum, - }; - - div() - .id("color-spectrum") - .relative() - .w_full() - .h(COLOR_PICKER_SPECTRUM_HEIGHT) - .rounded(px(5.0)) - .bg(hue) - .child( - div() - .absolute() - .inset_0() - .rounded(px(5.0)) - .bg(linear_gradient( - 90.0, - linear_color_stop(white, 0.0), - linear_color_stop(transparent_white, 1.0), - )), - ) - .child( - div() - .absolute() - .inset_0() - .rounded(px(5.0)) - .bg(linear_gradient( - 180.0, - linear_color_stop(transparent_black, 0.0), - linear_color_stop(black, 1.0), - )), - ) - .child( - div() - .absolute() - .left(relative(color.s)) - .top(relative(1.0 - color.v)) - .ml(-COLOR_PICKER_THUMB_SIZE.half()) - .mt(-COLOR_PICKER_THUMB_SIZE.half()) - .size(COLOR_PICKER_THUMB_SIZE) - .rounded(px(999.0)) - .border_2() - .border_color(white) - .shadow_md(), - ) - .on_prepaint({ - let entity = cx.entity().clone(); - move |bounds, _, cx| { - entity.update(cx, |picker, _| picker.spectrum_bounds = Some(bounds)); - } - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|picker, event: &MouseDownEvent, window, cx| { - picker.apply_from_position( - ComponentColorDragKind::Spectrum, - event.position, - window, - cx, - ); - cx.stop_propagation(); - }), - ) - .on_drag(control, |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }) - .on_drag_move(cx.listener( - move |picker, event: &DragMoveEvent, window, cx| { - let drag = event.drag(cx); - if drag.entity_id != entity_id || drag.kind != ComponentColorDragKind::Spectrum - { - return; - } - picker.apply_from_position( - ComponentColorDragKind::Spectrum, - event.event.position, - window, - cx, - ); - }, - )) - .into_any_element() - } - - fn render_hue_slider( - &self, - color: ComponentColorHsva, - entity_id: EntityId, - cx: &mut Context, - ) -> AnyElement { - let control = ComponentColorDrag { - entity_id, - kind: ComponentColorDragKind::Hue, - }; - div() - .id("color-hue-slider") - .relative() - .w_full() - .h(COLOR_PICKER_SLIDER_HEIGHT) - .rounded(px(999.0)) - .child( - div() - .absolute() - .inset_0() - .overflow_hidden() - .rounded(px(999.0)) - .flex() - .flex_row() - .children((0..48).map(|ix| { - div() - .flex_1() - .h_full() - .bg(gpui::hsla(ix as f32 / 47.0, 1.0, 0.5, 1.0)) - })), - ) - .child(component_color_slider_thumb(color.h)) - .on_prepaint({ - let entity = cx.entity().clone(); - move |bounds, _, cx| { - entity.update(cx, |picker, _| picker.hue_bounds = Some(bounds)); - } - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|picker, event: &MouseDownEvent, window, cx| { - picker.apply_from_position( - ComponentColorDragKind::Hue, - event.position, - window, - cx, - ); - cx.stop_propagation(); - }), - ) - .on_drag(control, |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }) - .on_drag_move(cx.listener( - move |picker, event: &DragMoveEvent, window, cx| { - let drag = event.drag(cx); - if drag.entity_id != entity_id || drag.kind != ComponentColorDragKind::Hue { - return; - } - picker.apply_from_position( - ComponentColorDragKind::Hue, - event.event.position, - window, - cx, - ); - }, - )) - .into_any_element() - } - - fn render_alpha_slider( - &self, - color: ComponentColorHsva, - entity_id: EntityId, - cx: &mut Context, - ) -> AnyElement { - let start = component_color_from_hsva(ComponentColorHsva { a: 0.0, ..color }); - let end = component_color_from_hsva(ComponentColorHsva { a: 1.0, ..color }); - let control = ComponentColorDrag { - entity_id, - kind: ComponentColorDragKind::Alpha, - }; - - div() - .id("color-alpha-slider") - .relative() - .w_full() - .h(COLOR_PICKER_SLIDER_HEIGHT) - .rounded(px(999.0)) - .child( - div() - .absolute() - .inset_0() - .overflow_hidden() - .rounded(px(999.0)) - .bg(linear_gradient( - 90.0, - linear_color_stop(start, 0.0), - linear_color_stop(end, 1.0), - )), - ) - .child(component_color_slider_thumb(color.a)) - .on_prepaint({ - let entity = cx.entity().clone(); - move |bounds, _, cx| { - entity.update(cx, |picker, _| picker.alpha_bounds = Some(bounds)); - } - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|picker, event: &MouseDownEvent, window, cx| { - picker.apply_from_position( - ComponentColorDragKind::Alpha, - event.position, - window, - cx, - ); - cx.stop_propagation(); - }), - ) - .on_drag(control, |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }) - .on_drag_move(cx.listener( - move |picker, event: &DragMoveEvent, window, cx| { - let drag = event.drag(cx); - if drag.entity_id != entity_id || drag.kind != ComponentColorDragKind::Alpha { - return; - } - picker.apply_from_position( - ComponentColorDragKind::Alpha, - event.event.position, - window, - cx, - ); - }, - )) - .into_any_element() - } -} - -impl Render for ComponentColorPicker { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let swatches = self.render_swatches(cx); - if !component_color_language(self.buffer.read(cx).language()) { - self.active = None; - self.active_hue = None; - self.active_saturation = None; - return component_color_overlay(swatches, None, cx); - } - - let Some(active) = self.active.clone() else { - return component_color_overlay(swatches, None, cx); - }; - - let position = { - let content = self.input.read(cx).value().to_string(); - if component_css_color_for_range(&content, &active.range).is_none() { - None - } else { - self.picker_anchor - .or_else(|| self.picker_anchor_for_range(&active.range, cx)) - } - }; - - let Some((left, top)) = position else { - self.active = None; - self.active_hue = None; - self.active_saturation = None; - self.picker_anchor = None; - return component_color_overlay(swatches, None, cx); - }; - self.picker_anchor = Some((left, top)); - let mut color = component_color_to_hsva(active.color); - if let Some(hue) = self.active_hue { - color.h = hue; - } - if let Some(saturation) = self.active_saturation { - color.s = saturation; - } - let entity_id = cx.entity_id(); - let theme = *cx.theme(); - - let picker = div() - .id("component-color-picker") - .absolute() - .left(left) - .top(top) - .w(COLOR_PICKER_WIDTH) - .flex() - .flex_col() - .gap(px(10.0)) - .p(px(10.0)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border_strong) - .bg(theme.bg_surface) - .shadow_lg() - .occlude() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_mouse_down_out(cx.listener(|picker, _, _, cx| { - picker.close(cx); - })) - .child(self.render_spectrum(color, entity_id, cx)) - .child(self.render_hue_slider(color, entity_id, cx)) - .child(self.render_alpha_slider(color, entity_id, cx)); - - component_color_overlay(swatches, Some(picker.into_any_element()), cx) - } -} - -fn component_color_overlay( - swatches: Vec, - picker: Option, - cx: &mut Context, -) -> AnyElement { - div() - .absolute() - .inset_0() - .on_prepaint({ - let entity = cx.entity().clone(); - move |bounds, _, cx| { - entity.update(cx, |picker, cx| { - if picker.overlay_bounds.is_none_or(|previous| { - previous.origin != bounds.origin || previous.size != bounds.size - }) { - picker.overlay_bounds = Some(bounds); - cx.notify(); - } - }); - } - }) - .children(swatches) - .when_some(picker, |this, picker| this.child(picker)) - .into_any_element() -} - -fn component_color_slider_thumb(value: f32) -> AnyElement { - div() - .absolute() - .left(relative(value.clamp(0.0, 1.0))) - .top(px(-3.0)) - .ml(-COLOR_PICKER_SLIDER_THUMB_WIDTH.half()) - .w(COLOR_PICKER_SLIDER_THUMB_WIDTH) - .h(COLOR_PICKER_SLIDER_THUMB_HEIGHT) - .rounded(px(999.0)) - .border_2() - .border_color(gpui::hsla(0.0, 0.0, 1.0, 1.0)) - .shadow_md() - .into_any_element() -} - -fn component_color_unit(value: f32) -> f32 { - if value.is_finite() { - value.clamp(0.0, 1.0) - } else { - 0.0 - } -} - -fn component_color_to_hsva(color: Hsla) -> ComponentColorHsva { - let rgba = color.to_rgb(); - let max = rgba.r.max(rgba.g).max(rgba.b); - let min = rgba.r.min(rgba.g).min(rgba.b); - let delta = max - min; - let hue = if delta == 0.0 { - color.h - } else if max == rgba.r { - ((rgba.g - rgba.b) / delta).rem_euclid(6.0) / 6.0 - } else if max == rgba.g { - ((rgba.b - rgba.r) / delta + 2.0) / 6.0 - } else { - ((rgba.r - rgba.g) / delta + 4.0) / 6.0 - }; - - ComponentColorHsva { - h: component_color_unit(hue), - s: if max == 0.0 { 0.0 } else { delta / max }, - v: max, - a: color.a, - } -} - -fn component_color_from_hsva(color: ComponentColorHsva) -> Hsla { - let h = color.h.rem_euclid(1.0) * 6.0; - let c = color.v * color.s; - let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs()); - let m = color.v - c; - let (r, g, b) = if h < 1.0 { - (c, x, 0.0) - } else if h < 2.0 { - (x, c, 0.0) - } else if h < 3.0 { - (0.0, c, x) - } else if h < 4.0 { - (0.0, x, c) - } else if h < 5.0 { - (x, 0.0, c) - } else { - (c, 0.0, x) - }; - - gpui::Rgba { - r: (r + m).clamp(0.0, 1.0), - g: (g + m).clamp(0.0, 1.0), - b: (b + m).clamp(0.0, 1.0), - a: component_color_unit(color.a), - } - .into() -} - -impl Render for ComponentCompletionMenu { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - if !self.is_open() || !self.input.focus_handle(cx).is_focused(window) { - return div().into_any_element(); - } - - let input = self.input.read(cx); - let content = input.value().to_string(); - let cursor = input.cursor(); - if cursor != self.replace_range.end { - self.clear_without_notify(); - return div().into_any_element(); - } - - let raw_query = completion_raw_query_for_offset(&content, cursor); - let query = completion_filter_query(&raw_query); - let visual_query = if query.is_empty() { &raw_query } else { &query }; - let anchor = cursor.saturating_sub(visual_query.len()); - let Some(bounds) = input.range_to_bounds(&(anchor..anchor)) else { - return div().into_any_element(); - }; - let origin = input - .range_to_bounds(&(0..0)) - .map(|bounds| bounds.origin) - .unwrap_or(bounds.origin); - let theme = *cx.theme(); - let left = (bounds.origin.x - origin.x - + completion_query_visual_width(visual_query, window)) - .max(px(0.0)); - let line_height = input.line_height().unwrap_or(bounds.size.height); - let top = completion_line_for_offset(&content, anchor) as f32 * line_height - + input.scroll_offset().y - + line_height - + px(4.0); - let desired_width = self - .width - .unwrap_or_else(|| completion_menu_desired_width(&self.items)); - let width = completion_menu_width(desired_width, window, left); - - let first_visible = self.first_visible; - let last_visible = self.last_visible(); - - div() - .absolute() - .left(left) - .top(top) - .w(width) - .max_h(px(280.0)) - .overflow_hidden() - .occlude() - .shadow_lg() - .rounded(rems(0.25)) - .border_1() - .border_color(theme.border_strong) - .bg(theme.bg_surface) - .p(rems(0.25)) - .text_xs() - .font_family(FONT_FAMILY) - .on_scroll_wheel(cx.listener(|menu, event: &gpui::ScrollWheelEvent, _, cx| { - let delta = event.delta.pixel_delta(px(16.0)).y; - let lines = if delta < Pixels::ZERO { - 1 - } else if delta > Pixels::ZERO { - -1 - } else { - 0 - }; - if menu.scroll(lines, cx) { - cx.stop_propagation(); - } - })) - .children( - self.items[first_visible..last_visible] - .iter() - .enumerate() - .map(|(visible_index, item)| { - let index = first_visible + visible_index; - let selected = index == self.selected; - div() - .id(index) - .w_full() - .flex() - .flex_row() - .items_center() - .justify_between() - .gap(rems(0.75)) - .px(rems(0.4)) - .py(rems(0.2)) - .rounded(rems(0.2)) - .text_color(theme.text) - .when(selected, |row| row.bg(theme.bg_selected)) - .hover(|row| row.bg(theme.bg_hover)) - .child(div().flex_none().child(item.label.clone())) - .when_some(item.detail.clone(), |row, detail| { - row.child( - div() - .min_w_0() - .overflow_hidden() - .text_color(theme.text_subtle) - .italic() - .child(detail), - ) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |menu, _, window, cx| { - if menu.accept_index(index, window, cx) { - cx.stop_propagation(); - } - }), - ) - }), - ) - .into_any_element() - } -} - -impl ComponentCompletionItem { - fn new(item: CompletionItem) -> Self { - let detail = completion_compact_detail(&item, completion_full_detail(&item).as_deref()); - Self { - label: item.label.clone(), - item, - detail, - } - } -} - -fn component_document_colors(text: &Rope, content: &str) -> Vec<(lsp_types::Range, Hsla)> { - component_css_colors(content) - .into_iter() - .map(|color| { - ( - lsp_types::Range::new( - text.offset_to_position(color.range.start), - text.offset_to_position(color.range.end), - ), - color.color, - ) - }) - .collect() -} - -fn component_document_color_information(text: &Rope, content: &str) -> Vec { - component_document_colors(text, content) - .into_iter() - .map(|(range, color)| { - let rgba = color.to_rgb(); - ColorInformation { - range, - color: Color { - red: rgba.r, - green: rgba.g, - blue: rgba.b, - alpha: rgba.a, - }, - } - }) - .collect() -} - -impl DocumentColorProvider for ComponentColorProvider { - fn document_colors( - &self, - text: &Rope, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - if !component_color_language(self.buffer.read(cx).language()) { - return Task::ready(Ok(Vec::new())); - } - - let content = text.to_string(); - Task::ready(Ok(component_document_color_information(text, &content))) - } -} - -impl HoverProvider for ComponentLspProvider { - fn hover( - &self, - text: &Rope, - offset: usize, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - let content = text.to_string(); - let position = text.offset_to_position(offset); - let request = { - let buffer = self.buffer.read(cx); - let Some(language) = buffer.language() else { - return Task::ready(Ok(None)); - }; - let language_id = language.as_str().to_string(); - if !lsp::has_installed_server(&language_id) { - return Task::ready(Ok(None)); - } - let path = buffer.path().to_path_buf(); - let Some(root) = self - .root - .clone() - .or_else(|| path.parent().map(Path::to_path_buf)) - else { - return Task::ready(Ok(None)); - }; - - lsp::HoverRequest { - root, - path, - language_id, - content, - position: lsp::Position { - line: position.line, - character: position.character, - }, - } - }; - - cx.background_executor().spawn(async move { - let hover = lsp::hover(request).map_err(anyhow::Error::from)?; - Ok(hover.map(|hover| lsp_types::Hover { - contents: lsp_types::HoverContents::Scalar(lsp_types::MarkedString::String( - hover.contents, - )), - range: None, - })) - }) - } -} - -fn component_editor_language(language: &str) -> &str { - match language { - "shellscript" => "bash", - "typescriptreact" => "tsx", - // gpui-component does not currently ship a separate JSX language. - "javascriptreact" => "javascript", - other => other, - } -} - -fn component_diagnostics_from_lsp( - diagnostics: Vec, - content: &str, -) -> Vec { - let lines = content.split('\n').collect::>(); - diagnostics - .into_iter() - .map(|diagnostic| component_diagnostic_from_lsp(diagnostic, &lines)) - .collect() -} - -fn component_diagnostic_from_lsp(diagnostic: LspDiagnostic, lines: &[&str]) -> ComponentDiagnostic { - let range = component_position_from_lsp(diagnostic.range.start, lines) - ..component_position_from_lsp(diagnostic.range.end, lines); - let mut diagnostic = ComponentDiagnostic::from(diagnostic); - diagnostic.range = range; - diagnostic -} - -fn component_position_from_lsp(position: LspPosition, lines: &[&str]) -> LspPosition { - let line = lines - .get(position.line as usize) - .copied() - .unwrap_or_default(); - let byte_index = byte_index_for_utf16_column(line, position.character as usize); - let character = line[..byte_index].chars().count() as u32; - LspPosition::new(position.line, character) -} - -fn byte_index_for_utf16_column(line: &str, utf16_column: usize) -> usize { - let mut current_column = 0; - for (byte_index, ch) in line.char_indices() { - let next_column = current_column + ch.len_utf16(); - if next_column > utf16_column { - return byte_index; - } - if next_column == utf16_column { - return byte_index + ch.len_utf8(); - } - current_column = next_column; - } - line.len() -} - -fn empty_completion_response() -> CompletionResponse { - CompletionResponse::Array(Vec::new()) -} - -fn completion_should_request(query: &str) -> bool { - let query = query.trim(); - if query.is_empty() { - return false; - } - if query.chars().last().is_some_and(completion_query_char) { - return true; - } - - query.ends_with('.') || query.ends_with("::") || query.ends_with("->") -} - -fn completion_raw_query_for_offset(content: &str, offset: usize) -> String { - let offset = clamp_to_char_boundary(content, offset); - let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1); - let prefix = &content[line_start..offset]; - let start = prefix - .char_indices() - .rev() - .find_map(|(index, ch)| completion_query_separator(ch).then_some(index + ch.len_utf8())) - .unwrap_or(0); - prefix[start..].to_string() -} - -fn completion_query_separator(ch: char) -> bool { - ch.is_whitespace() || matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';') -} - -fn enhance_completion_response(response: &mut CompletionResponse) { - match response { - CompletionResponse::Array(items) => enhance_completion_items(items), - CompletionResponse::List(list) => enhance_completion_items(&mut list.items), - } -} - -fn enhance_completion_items(items: &mut [CompletionItem]) { - for item in items { - enhance_completion_item(item); - } -} - -fn enhance_completion_item(item: &mut CompletionItem) { - let full_detail = completion_full_detail(item); - if item.documentation.is_none() - && completion_has_extra_detail(item) - && let Some(detail) = full_detail.clone() - { - item.documentation = Some(Documentation::String(detail)); - } -} - -fn completion_has_extra_detail(item: &CompletionItem) -> bool { - item.detail - .as_deref() - .is_some_and(|detail| !detail.trim().is_empty()) - || item.label_details.as_ref().is_some_and(|details| { - details - .detail - .as_deref() - .is_some_and(|detail| !detail.trim().is_empty()) - || details - .description - .as_deref() - .is_some_and(|description| !description.trim().is_empty()) - }) -} - -fn completion_compact_detail(item: &CompletionItem, full_detail: Option<&str>) -> Option { - if let Some(detail) = item.detail.as_deref() - && detail.chars().count() <= MAX_COMPLETION_DETAIL_CHARS - { - return Some(detail.to_string()); - } - - let kind = item.kind.and_then(completion_kind_label); - let label_detail = item - .label_details - .as_ref() - .and_then(|details| details.detail.as_deref()) - .map(str::trim) - .filter(|detail| !detail.is_empty()); - - match (kind, label_detail, full_detail) { - (Some(kind), Some(label_detail), _) => Some(format!("({kind}) {label_detail}")), - (Some(kind), None, _) => Some(format!("({kind})")), - (None, Some(label_detail), _) => Some(label_detail.to_string()), - (None, None, Some(detail)) => Some(truncate_completion_detail(detail)), - (None, None, None) => None, - } -} - -fn completion_full_detail(item: &CompletionItem) -> Option { - let mut parts = Vec::new(); - let detail = item - .detail - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - if !detail.is_some_and(|detail| detail.starts_with('(')) - && let Some(kind) = item.kind.and_then(completion_kind_label) - { - parts.push(format!("({kind})")); - } - if let Some(detail) = detail { - parts.push(detail.to_string()); - } - if let Some(label_details) = item.label_details.as_ref() { - if let Some(detail) = label_details - .detail - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - parts.push(detail.to_string()); - } - if let Some(description) = label_details - .description - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - parts.push(description.to_string()); - } - } - - (!parts.is_empty()).then(|| parts.join(" ")) -} - -fn truncate_completion_detail(detail: &str) -> String { - let mut chars = detail.chars(); - let truncated = chars - .by_ref() - .take(MAX_COMPLETION_DETAIL_CHARS) - .collect::(); - if chars.next().is_some() { - format!("{truncated}...") - } else { - truncated - } -} - -fn completion_kind_label(kind: CompletionItemKind) -> Option<&'static str> { - if kind == CompletionItemKind::METHOD { - Some("method") - } else if kind == CompletionItemKind::FUNCTION { - Some("function") - } else if kind == CompletionItemKind::CONSTRUCTOR { - Some("constructor") - } else if kind == CompletionItemKind::FIELD { - Some("field") - } else if kind == CompletionItemKind::VARIABLE { - Some("variable") - } else if kind == CompletionItemKind::CLASS { - Some("class") - } else if kind == CompletionItemKind::INTERFACE { - Some("interface") - } else if kind == CompletionItemKind::MODULE { - Some("module") - } else if kind == CompletionItemKind::PROPERTY { - Some("property") - } else if kind == CompletionItemKind::ENUM { - Some("enum") - } else if kind == CompletionItemKind::KEYWORD { - Some("keyword") - } else if kind == CompletionItemKind::SNIPPET { - Some("snippet") - } else if kind == CompletionItemKind::FILE { - Some("file") - } else if kind == CompletionItemKind::FOLDER { - Some("folder") - } else if kind == CompletionItemKind::ENUM_MEMBER { - Some("enum member") - } else if kind == CompletionItemKind::CONSTANT { - Some("constant") - } else if kind == CompletionItemKind::STRUCT { - Some("struct") - } else if kind == CompletionItemKind::EVENT { - Some("event") - } else if kind == CompletionItemKind::OPERATOR { - Some("operator") - } else if kind == CompletionItemKind::TYPE_PARAMETER { - Some("type parameter") - } else { - None - } -} - -fn rank_completion_response(mut response: CompletionResponse, query: &str) -> CompletionResponse { - if query.is_empty() { - return response; - } - - match &mut response { - CompletionResponse::Array(items) => rank_completion_items(items, query), - CompletionResponse::List(list) => rank_completion_items(&mut list.items, query), - } - response -} - -fn rank_completion_items(items: &mut [CompletionItem], query: &str) { - items.sort_by(|a, b| completion_match_score(a, query).cmp(&completion_match_score(b, query))); -} - -fn completion_match_score(item: &CompletionItem, query: &str) -> (u8, usize, usize) { - let text = item - .filter_text - .as_deref() - .unwrap_or(item.label.as_str()) - .to_lowercase(); - - if text == query { - return (0, 0, text.len()); - } - if text.starts_with(query) { - return (1, 0, text.len()); - } - if let Some(index) = completion_word_boundary_match(&text, query) { - return (2, index, text.len()); - } - if let Some(index) = text.find(query) { - return (3, index, text.len()); - } - if let Some((index, span)) = completion_fuzzy_match(&text, query) { - return (4, index, span); - } - - (5, usize::MAX, text.len()) -} - -fn completion_filter_query(query: &str) -> String { - let query = query.trim().to_lowercase(); - let start = query - .char_indices() - .rev() - .find_map(|(index, ch)| (!completion_query_char(ch)).then_some(index + ch.len_utf8())) - .unwrap_or(0); - query[start..].to_string() -} - -fn completion_word_boundary_match(text: &str, query: &str) -> Option { - text.match_indices(query) - .find(|(index, _)| { - *index == 0 - || text[..*index] - .chars() - .last() - .is_some_and(|ch| !completion_query_char(ch)) - }) - .map(|(index, _)| index) -} - -fn completion_fuzzy_match(text: &str, query: &str) -> Option<(usize, usize)> { - let mut query_chars = query.chars(); - let mut next = query_chars.next()?; - let mut first = None; - - for (index, ch) in text.char_indices() { - if ch != next { - continue; - } - - let first_index = *first.get_or_insert(index); - match query_chars.next() { - Some(ch) => next = ch, - None => return Some((first_index, index + ch.len_utf8() - first_index)), - } - } - - None -} - -fn completion_query_char(ch: char) -> bool { - ch.is_alphanumeric() || matches!(ch, '_' | '-' | '$') -} - -fn completion_response_items(response: CompletionResponse) -> Vec { - match response { - CompletionResponse::Array(items) => items, - CompletionResponse::List(list) => list.items, - } -} - -fn completion_edit_for_item( - item: &CompletionItem, - content: &str, - fallback_range: Range, -) -> (Range, String) { - if let Some(text_edit) = item.text_edit.as_ref() { - return match text_edit { - CompletionTextEdit::Edit(edit) => ( - component_lsp_range_to_offset(content, edit.range), - edit.new_text.clone(), - ), - CompletionTextEdit::InsertAndReplace(edit) => ( - component_lsp_range_to_offset(content, edit.replace), - edit.new_text.clone(), - ), - }; - } - - let new_text = item - .insert_text - .clone() - .unwrap_or_else(|| item.label.clone()); - (fallback_range, new_text) -} - -fn component_lsp_range_to_offset(content: &str, range: lsp_types::Range) -> Range { - component_lsp_position_to_offset(content, range.start) - ..component_lsp_position_to_offset(content, range.end) -} - -fn component_lsp_position_to_offset(content: &str, position: lsp_types::Position) -> usize { - let mut line = 0u32; - let mut character = 0u32; - - for (offset, ch) in content.char_indices() { - if line == position.line && character >= position.character { - return offset; - } - if ch == '\n' { - line += 1; - character = 0; - } else { - character += ch.len_utf16() as u32; - } - } - - content.len() -} - -fn completion_menu_desired_width(items: &[ComponentCompletionItem]) -> Pixels { - let max_chars = items - .iter() - .map(|item| { - item.label.chars().count() - + item - .detail - .as_deref() - .map(|detail| detail.chars().count() + 2) - .unwrap_or(0) - }) - .max() - .unwrap_or(0) as f32; - (px(max_chars * COMPLETION_MENU_CHAR_WIDTH) + COMPLETION_MENU_HORIZONTAL_PADDING) - .max(COMPLETION_MENU_MIN_WIDTH) - .min(COMPLETION_MENU_MAX_WIDTH) -} - -fn completion_menu_width(desired: Pixels, window: &Window, left: Pixels) -> Pixels { - let available = (window.bounds().size.width - left - px(8.0)).max(px(80.0)); - desired.min(available) -} - -fn completion_query_visual_width(query: &str, window: &Window) -> Pixels { - px(query.chars().count() as f32 - * f32::from(window.rem_size()) - * FONT_SIZE_REM - * COMPLETION_CURSOR_CHAR_WIDTH_FACTOR) -} - -fn completion_line_for_offset(content: &str, offset: usize) -> usize { - let offset = clamp_to_char_boundary(content, offset); - content[..offset] - .bytes() - .filter(|byte| *byte == b'\n') - .count() -} - -fn clamp_to_char_boundary(content: &str, mut offset: usize) -> usize { - offset = offset.min(content.len()); - while offset > 0 && !content.is_char_boundary(offset) { - offset -= 1; - } - offset -} - -fn copy_current_component_line(input: &Entity, cx: &mut Context) { - let Some((content, range)) = current_component_line_range(input, cx) else { - return; - }; - if range.is_empty() { - return; - } - - cx.write_to_clipboard(ClipboardItem::new_string(content[range].to_string())); - cx.stop_propagation(); -} - -fn cut_current_component_line( - input: &Entity, - window: &mut Window, - cx: &mut Context, -) -> bool { - let Some((content, range)) = current_component_line_range(input, cx) else { - return false; - }; - if range.is_empty() { - return false; - } - - cx.write_to_clipboard(ClipboardItem::new_string( - content[range.clone()].to_string(), - )); - let utf16_range = component_range_to_utf16(&content, &range); - input.update(cx, |input, cx| { - EntityInputHandler::replace_text_in_range(input, Some(utf16_range), "", window, cx); - }); - true -} - -fn insert_component_auto_pair( - input: &Entity, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut App, -) -> bool { - if event.keystroke.modifiers.control - || event.keystroke.modifiers.alt - || event.keystroke.modifiers.platform - { - return false; - } - - let Some((open, close)) = component_auto_pair(event.keystroke.key.as_str()) else { - return false; - }; - - input.update(cx, |input, cx| { - let content = input.value().to_string(); - let selected_range = input.selected_range(); - if !selected_range.is_empty() { - return false; - } - - let utf16_range = component_range_to_utf16(&content, &selected_range); - let replacement = format!("{open}{close}"); - EntityInputHandler::replace_text_in_range( - input, - Some(utf16_range), - &replacement, - window, - cx, - ); - - let position = input - .text() - .offset_to_position(selected_range.start + open.len_utf8()); - input.set_cursor_position(position, window, cx); - true - }) -} - -fn component_auto_pair(key: &str) -> Option<(char, char)> { - match key { - "(" => Some(('(', ')')), - "[" => Some(('[', ']')), - "{" => Some(('{', '}')), - "\"" => Some(('\"', '\"')), - "'" => Some(('\'', '\'')), - "`" => Some(('`', '`')), - _ => None, - } -} - -fn current_component_line_range( - input: &Entity, - cx: &Context, -) -> Option<(String, Range)> { - let input = input.read(cx); - let selection = input.selected_range(); - if !selection.is_empty() { - return None; - } - - let content = input.value().to_string(); - let range = component_line_range_for_offset(&content, selection.start); - Some((content, range)) -} - -fn component_line_range_for_offset(content: &str, offset: usize) -> Range { - let offset = offset.min(content.len()); - let start = content[..offset].rfind('\n').map_or(0, |index| index + 1); - let end = content[offset..] - .find('\n') - .map_or(content.len(), |index| offset + index + 1); - start..end -} - -fn component_range_to_utf16(content: &str, range: &Range) -> Range { - component_offset_to_utf16(content, range.start)..component_offset_to_utf16(content, range.end) -} - -fn component_offset_to_utf16(content: &str, offset: usize) -> usize { - let mut utf16_offset = 0usize; - let mut utf8_offset = 0usize; - for ch in content.chars() { - if utf8_offset >= offset { - break; - } - utf8_offset += ch.len_utf8(); - utf16_offset += ch.len_utf16(); - } - utf16_offset -} - -pub fn install_default_keybindings(cx: &mut App) { - #[cfg(not(target_os = "macos"))] - cx.bind_keys([ - KeyBinding::new( - "alt-left", - gpui_component::input::MoveToPreviousWord, - Some(COMPONENT_INPUT_KEY_CONTEXT), - ), - KeyBinding::new( - "alt-right", - gpui_component::input::MoveToNextWord, - Some(COMPONENT_INPUT_KEY_CONTEXT), - ), - KeyBinding::new( - "alt-shift-left", - gpui_component::input::SelectToPreviousWordStart, - Some(COMPONENT_INPUT_KEY_CONTEXT), - ), - KeyBinding::new( - "alt-shift-right", - gpui_component::input::SelectToNextWordEnd, - Some(COMPONENT_INPUT_KEY_CONTEXT), - ), - ]); -} diff --git a/crates/ui/src/tabs/file_editor/parts/color_support.rs b/crates/ui/src/tabs/file_editor/parts/color_support.rs deleted file mode 100644 index c2253df8..00000000 --- a/crates/ui/src/tabs/file_editor/parts/color_support.rs +++ /dev/null @@ -1,688 +0,0 @@ -#[derive(Clone, Debug, PartialEq)] -struct ComponentCssColor { - range: std::ops::Range, - color: gpui::Hsla, - format: ComponentCssColorFormat, -} - -#[derive(Clone, Debug, PartialEq)] -enum ComponentCssColorFormat { - Hex { alpha: bool }, - Rgb { alpha: bool }, - Hsl { alpha: bool }, - Hwb { alpha: bool }, - Oklab { alpha: bool }, - Oklch { alpha: bool }, -} - -struct ComponentCssFunctionArgs<'a> { - channels: Vec<&'a str>, - alpha: Option<&'a str>, -} - -#[derive(Clone, Copy)] -struct ComponentCssNumber { - value: f32, - percent: bool, -} - -fn component_color_language(language: Option<&language::LanguageId>) -> bool { - language.is_some_and(|language| { - matches!( - language.as_str(), - "css" - | "scss" - | "sass" - | "less" - | "html" - | "astro" - | "svelte" - | "javascriptreact" - | "typescriptreact" - ) - }) -} - -fn component_css_colors(content: &str) -> Vec { - let mut colors = Vec::new(); - let mut index = 0; - - while index < content.len() { - if let Some(color) = component_css_hex_color_at(content, index) - .or_else(|| component_css_function_color_at(content, index)) - { - index = color.range.end; - colors.push(color); - continue; - } - - let Some(ch) = content[index..].chars().next() else { - break; - }; - index += ch.len_utf8(); - } - - colors -} - -fn component_css_color_for_range( - content: &str, - range: &std::ops::Range, -) -> Option { - component_css_colors(content) - .into_iter() - .find(|color| color.range == *range) -} - -fn component_css_hex_color_at(content: &str, index: usize) -> Option { - if !content[index..].starts_with('#') || !component_css_boundary_before(content, index) { - return None; - } - - let mut end = index + 1; - let mut digits = String::new(); - for ch in content[end..].chars() { - if !ch.is_ascii_hexdigit() || digits.len() == 8 { - break; - } - digits.push(ch); - end += ch.len_utf8(); - } - - if content[end..] - .chars() - .next() - .is_some_and(|ch| ch.is_ascii_hexdigit()) - { - return None; - } - - let alpha = match digits.len() { - 3 | 6 => false, - 4 | 8 => true, - _ => return None, - }; - let rgba = component_css_hex_to_rgba(&digits)?; - - Some(ComponentCssColor { - range: index..end, - color: rgba.into(), - format: ComponentCssColorFormat::Hex { alpha }, - }) -} - -fn component_css_hex_to_rgba(digits: &str) -> Option { - fn channel(digits: &str, index: usize, short: bool) -> Option { - let value = if short { - let d = &digits[index..index + 1]; - u8::from_str_radix(&format!("{d}{d}"), 16).ok()? - } else { - u8::from_str_radix(&digits[index..index + 2], 16).ok()? - }; - Some(value as f32 / 255.0) - } - - let short = matches!(digits.len(), 3 | 4); - let step = if short { 1 } else { 2 }; - Some(gpui::Rgba { - r: channel(digits, 0, short)?, - g: channel(digits, step, short)?, - b: channel(digits, step * 2, short)?, - a: if matches!(digits.len(), 4 | 8) { - channel(digits, step * 3, short)? - } else { - 1.0 - }, - }) -} - -fn component_css_function_color_at(content: &str, index: usize) -> Option { - if !component_css_boundary_before(content, index) { - return None; - } - - for function in ["rgba", "rgb", "hsla", "hsl", "hwb", "oklch", "oklab"] { - if !component_css_starts_with_ignore_ascii(&content[index..], function) { - continue; - } - - let open = index + function.len(); - if !content[open..].starts_with('(') { - continue; - } - - let close = component_css_matching_paren(content, open)?; - let args = &content[open + 1..close]; - let (color, format) = component_css_parse_function(function, args)?; - return Some(ComponentCssColor { - range: index..close + 1, - color, - format, - }); - } - - None -} - -fn component_css_parse_function( - function: &str, - args: &str, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - let parsed = component_css_parse_function_args(args)?; - match function { - "rgb" | "rgba" => component_css_parse_rgb(function == "rgba", parsed), - "hsl" | "hsla" => component_css_parse_hsl(function == "hsla", parsed), - "hwb" => component_css_parse_hwb(parsed), - "oklab" => component_css_parse_oklab(parsed), - "oklch" => component_css_parse_oklch(parsed), - _ => None, - } -} - -fn component_css_parse_rgb( - function_alpha: bool, - args: ComponentCssFunctionArgs<'_>, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - if args.channels.len() != 3 { - return None; - } - - let alpha = args - .alpha - .map(component_css_parse_alpha) - .unwrap_or(Some(1.0))?; - let rgba = gpui::Rgba { - r: component_css_parse_rgb_channel(args.channels[0])?, - g: component_css_parse_rgb_channel(args.channels[1])?, - b: component_css_parse_rgb_channel(args.channels[2])?, - a: alpha, - }; - - Some(( - rgba.into(), - ComponentCssColorFormat::Rgb { - alpha: function_alpha || args.alpha.is_some(), - }, - )) -} - -fn component_css_parse_hsl( - function_alpha: bool, - args: ComponentCssFunctionArgs<'_>, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - if args.channels.len() != 3 { - return None; - } - - let alpha = args - .alpha - .map(component_css_parse_alpha) - .unwrap_or(Some(1.0))?; - let color = gpui::hsla( - component_css_parse_angle(args.channels[0])? / 360.0, - component_css_parse_unit_interval(args.channels[1])?, - component_css_parse_unit_interval(args.channels[2])?, - alpha, - ); - - Some(( - color, - ComponentCssColorFormat::Hsl { - alpha: function_alpha || args.alpha.is_some(), - }, - )) -} - -fn component_css_parse_hwb( - args: ComponentCssFunctionArgs<'_>, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - if args.channels.len() != 3 { - return None; - } - - let hue = component_css_parse_angle(args.channels[0])? / 360.0; - let white = component_css_parse_unit_interval(args.channels[1])?; - let black = component_css_parse_unit_interval(args.channels[2])?; - let alpha = args - .alpha - .map(component_css_parse_alpha) - .unwrap_or(Some(1.0))?; - let sum = white + black; - let rgba = if sum >= 1.0 { - let gray = if sum == 0.0 { 0.0 } else { white / sum }; - gpui::Rgba { - r: gray, - g: gray, - b: gray, - a: alpha, - } - } else { - let base = gpui::hsla(hue, 1.0, 0.5, alpha).to_rgb(); - let factor = 1.0 - sum; - gpui::Rgba { - r: base.r * factor + white, - g: base.g * factor + white, - b: base.b * factor + white, - a: alpha, - } - }; - - Some(( - rgba.into(), - ComponentCssColorFormat::Hwb { - alpha: args.alpha.is_some(), - }, - )) -} - -fn component_css_parse_oklab( - args: ComponentCssFunctionArgs<'_>, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - if args.channels.len() != 3 { - return None; - } - - let lightness = component_css_parse_ok_lightness(args.channels[0])?; - let a = component_css_parse_ok_axis(args.channels[1])?; - let b = component_css_parse_ok_axis(args.channels[2])?; - let alpha = args - .alpha - .map(component_css_parse_alpha) - .unwrap_or(Some(1.0))?; - let mut rgba = component_css_oklab_to_rgba(lightness, a, b); - rgba.a = alpha; - - Some(( - rgba.into(), - ComponentCssColorFormat::Oklab { - alpha: args.alpha.is_some(), - }, - )) -} - -fn component_css_parse_oklch( - args: ComponentCssFunctionArgs<'_>, -) -> Option<(gpui::Hsla, ComponentCssColorFormat)> { - if args.channels.len() != 3 { - return None; - } - - let lightness = component_css_parse_ok_lightness(args.channels[0])?; - let chroma = component_css_parse_ok_axis(args.channels[1])?.max(0.0); - let hue = component_css_parse_angle(args.channels[2])?.to_radians(); - let alpha = args - .alpha - .map(component_css_parse_alpha) - .unwrap_or(Some(1.0))?; - let mut rgba = component_css_oklab_to_rgba(lightness, chroma * hue.cos(), chroma * hue.sin()); - rgba.a = alpha; - - Some(( - rgba.into(), - ComponentCssColorFormat::Oklch { - alpha: args.alpha.is_some(), - }, - )) -} - -fn component_css_parse_function_args(args: &str) -> Option> { - let comma_parts = component_css_split_top_level(args, ','); - if comma_parts.len() > 1 { - if comma_parts.len() != 3 && comma_parts.len() != 4 { - return None; - } - let channels = comma_parts[..3] - .iter() - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .collect::>(); - if channels.len() != 3 { - return None; - } - return Some(ComponentCssFunctionArgs { - channels, - alpha: comma_parts.get(3).map(|part| part.trim()), - }); - } - - let slash_parts = component_css_split_top_level(args, '/'); - if slash_parts.len() > 2 { - return None; - } - - let channels = slash_parts[0] - .split_whitespace() - .map(str::trim) - .filter(|part| !part.is_empty()) - .collect::>(); - Some(ComponentCssFunctionArgs { - channels, - alpha: slash_parts.get(1).map(|part| part.trim()), - }) -} - -fn component_css_split_top_level(input: &str, delimiter: char) -> Vec<&str> { - let mut depth = 0usize; - let mut start = 0usize; - let mut parts = Vec::new(); - - for (index, ch) in input.char_indices() { - match ch { - '(' => depth += 1, - ')' => depth = depth.saturating_sub(1), - _ if ch == delimiter && depth == 0 => { - parts.push(&input[start..index]); - start = index + ch.len_utf8(); - } - _ => {} - } - } - - parts.push(&input[start..]); - parts -} - -fn component_css_parse_rgb_channel(token: &str) -> Option { - let number = component_css_parse_number(token)?; - let value = if number.percent { - number.value / 100.0 - } else { - number.value / 255.0 - }; - Some(value.clamp(0.0, 1.0)) -} - -fn component_css_parse_unit_interval(token: &str) -> Option { - let number = component_css_parse_number(token)?; - let value = if number.percent || number.value > 1.0 { - number.value / 100.0 - } else { - number.value - }; - Some(value.clamp(0.0, 1.0)) -} - -fn component_css_parse_ok_lightness(token: &str) -> Option { - let number = component_css_parse_number(token)?; - let value = if number.percent || number.value > 1.0 { - number.value / 100.0 - } else { - number.value - }; - Some(value.clamp(0.0, 1.0)) -} - -fn component_css_parse_ok_axis(token: &str) -> Option { - let number = component_css_parse_number(token)?; - Some(if number.percent { - number.value * 0.004 - } else { - number.value - }) -} - -fn component_css_parse_alpha(token: &str) -> Option { - let number = component_css_parse_number(token)?; - let value = if number.percent { - number.value / 100.0 - } else { - number.value - }; - Some(value.clamp(0.0, 1.0)) -} - -fn component_css_parse_number(token: &str) -> Option { - let token = token.trim(); - if token.eq_ignore_ascii_case("none") || token.is_empty() { - return None; - } - - let (number, percent) = token - .strip_suffix('%') - .map(|number| (number, true)) - .unwrap_or((token, false)); - Some(ComponentCssNumber { - value: number.trim().parse().ok()?, - percent, - }) -} - -fn component_css_parse_angle(token: &str) -> Option { - let token = token.trim().to_ascii_lowercase(); - if token == "none" || token.is_empty() { - return None; - } - - if let Some(value) = token.strip_suffix("turn") { - return Some(value.trim().parse::().ok()? * 360.0); - } - if let Some(value) = token.strip_suffix("grad") { - return Some(value.trim().parse::().ok()? * 0.9); - } - if let Some(value) = token.strip_suffix("rad") { - return Some(value.trim().parse::().ok()? * 180.0 / std::f32::consts::PI); - } - if let Some(value) = token.strip_suffix("deg") { - return value.trim().parse::().ok(); - } - - token.parse::().ok() -} - -fn component_css_format_color(format: &ComponentCssColorFormat, color: gpui::Hsla) -> String { - match format { - ComponentCssColorFormat::Hex { alpha } => component_css_format_hex(color, *alpha), - ComponentCssColorFormat::Rgb { alpha } => component_css_format_rgb(color, *alpha), - ComponentCssColorFormat::Hsl { alpha } => component_css_format_hsl(color, *alpha), - ComponentCssColorFormat::Hwb { alpha } => component_css_format_hwb(color, *alpha), - ComponentCssColorFormat::Oklab { alpha } => component_css_format_oklab(color, *alpha), - ComponentCssColorFormat::Oklch { alpha } => component_css_format_oklch(color, *alpha), - } -} - -fn component_css_format_hex(color: gpui::Hsla, original_alpha: bool) -> String { - let rgba = color.to_rgb(); - let r = component_css_byte(rgba.r); - let g = component_css_byte(rgba.g); - let b = component_css_byte(rgba.b); - let a = component_css_byte(color.a); - if original_alpha || color.a < 0.999 { - format!("#{r:02X}{g:02X}{b:02X}{a:02X}") - } else { - format!("#{r:02X}{g:02X}{b:02X}") - } -} - -fn component_css_format_rgb(color: gpui::Hsla, original_alpha: bool) -> String { - let rgba = color.to_rgb(); - let r = component_css_byte(rgba.r); - let g = component_css_byte(rgba.g); - let b = component_css_byte(rgba.b); - if original_alpha || color.a < 0.999 { - format!( - "rgba({r}, {g}, {b}, {})", - component_css_format_alpha(color.a) - ) - } else { - format!("rgb({r}, {g}, {b})") - } -} - -fn component_css_format_hsl(color: gpui::Hsla, original_alpha: bool) -> String { - let h = component_css_format_number((color.h * 360.0).rem_euclid(360.0), 1); - let s = component_css_format_number(color.s * 100.0, 1); - let l = component_css_format_number(color.l * 100.0, 1); - if original_alpha || color.a < 0.999 { - format!( - "hsla({h}, {s}%, {l}%, {})", - component_css_format_alpha(color.a) - ) - } else { - format!("hsl({h}, {s}%, {l}%)") - } -} - -fn component_css_format_hwb(color: gpui::Hsla, original_alpha: bool) -> String { - let rgba = color.to_rgb(); - let h = component_css_format_number((color.h * 360.0).rem_euclid(360.0), 1); - let w = component_css_format_number(rgba.r.min(rgba.g).min(rgba.b) * 100.0, 1); - let b = component_css_format_number((1.0 - rgba.r.max(rgba.g).max(rgba.b)) * 100.0, 1); - if original_alpha || color.a < 0.999 { - format!( - "hwb({h} {w}% {b}% / {})", - component_css_format_alpha(color.a) - ) - } else { - format!("hwb({h} {w}% {b}%)") - } -} - -fn component_css_format_oklab(color: gpui::Hsla, original_alpha: bool) -> String { - let (l, a, b) = component_css_rgba_to_oklab(color.to_rgb()); - let l = component_css_format_number(l * 100.0, 2); - let a = component_css_format_number(a, 4); - let b = component_css_format_number(b, 4); - if original_alpha || color.a < 0.999 { - format!( - "oklab({l}% {a} {b} / {})", - component_css_format_alpha(color.a) - ) - } else { - format!("oklab({l}% {a} {b})") - } -} - -fn component_css_format_oklch(color: gpui::Hsla, original_alpha: bool) -> String { - let (l, a, b) = component_css_rgba_to_oklab(color.to_rgb()); - let chroma = (a * a + b * b).sqrt(); - let hue = b.atan2(a).to_degrees().rem_euclid(360.0); - let l = component_css_format_number(l * 100.0, 2); - let chroma = component_css_format_number(chroma, 4); - let hue = component_css_format_number(hue, 1); - if original_alpha || color.a < 0.999 { - format!( - "oklch({l}% {chroma} {hue} / {})", - component_css_format_alpha(color.a) - ) - } else { - format!("oklch({l}% {chroma} {hue})") - } -} - -fn component_css_format_alpha(alpha: f32) -> String { - component_css_format_number(alpha.clamp(0.0, 1.0), 3) -} - -fn component_css_format_number(value: f32, decimals: usize) -> String { - let value = if value.abs() < 0.000_5 { 0.0 } else { value }; - let mut text = match decimals { - 0 => format!("{value:.0}"), - 1 => format!("{value:.1}"), - 2 => format!("{value:.2}"), - 3 => format!("{value:.3}"), - _ => format!("{value:.4}"), - }; - if text.contains('.') { - while text.ends_with('0') { - text.pop(); - } - if text.ends_with('.') { - text.pop(); - } - } - if text == "-0" { "0".to_string() } else { text } -} - -fn component_css_byte(value: f32) -> u8 { - (value.clamp(0.0, 1.0) * 255.0).round() as u8 -} - -fn component_css_boundary_before(content: &str, index: usize) -> bool { - content[..index] - .chars() - .next_back() - .is_none_or(|ch| !component_css_ident_char(ch)) -} - -fn component_css_ident_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') -} - -fn component_css_matching_paren(content: &str, open: usize) -> Option { - let mut depth = 0usize; - for (offset, ch) in content[open..].char_indices() { - match ch { - '(' => depth += 1, - ')' => { - depth = depth.saturating_sub(1); - if depth == 0 { - return Some(open + offset); - } - } - _ => {} - } - } - None -} - -fn component_css_starts_with_ignore_ascii(content: &str, prefix: &str) -> bool { - let bytes = content.as_bytes(); - let prefix = prefix.as_bytes(); - bytes.len() >= prefix.len() && bytes[..prefix.len()].eq_ignore_ascii_case(prefix) -} - -fn component_css_to_linear(channel: f32) -> f32 { - if channel <= 0.04045 { - channel / 12.92 - } else { - ((channel + 0.055) / 1.055).powf(2.4) - } -} - -fn component_css_from_linear(channel: f32) -> f32 { - if channel <= 0.003_130_8 { - channel * 12.92 - } else { - 1.055 * channel.powf(1.0 / 2.4) - 0.055 - } -} - -fn component_css_rgba_to_oklab(rgb: gpui::Rgba) -> (f32, f32, f32) { - let lr = component_css_to_linear(rgb.r); - let lg = component_css_to_linear(rgb.g); - let lb = component_css_to_linear(rgb.b); - - let l = 0.412_221_46 * lr + 0.536_332_55 * lg + 0.051_445_995 * lb; - let m = 0.211_903_5 * lr + 0.680_699_5 * lg + 0.107_396_96 * lb; - let s = 0.088_302_46 * lr + 0.281_718_85 * lg + 0.629_978_7 * lb; - - let l = l.cbrt(); - let m = m.cbrt(); - let s = s.cbrt(); - - ( - 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, - 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, - 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, - ) -} - -fn component_css_oklab_to_rgba(l: f32, a: f32, b: f32) -> gpui::Rgba { - let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b; - let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b; - let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b; - - let l = l_ * l_ * l_; - let m = m_ * m_ * m_; - let s = s_ * s_ * s_; - - gpui::Rgba { - r: component_css_from_linear(4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s) - .clamp(0.0, 1.0), - g: component_css_from_linear(-1.268_438 * l + 2.609_757_4 * m - 0.341_319_4 * s) - .clamp(0.0, 1.0), - b: component_css_from_linear(-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s) - .clamp(0.0, 1.0), - a: 1.0, - } -} diff --git a/crates/ui/src/tabs/file_editor/parts/render.rs b/crates/ui/src/tabs/file_editor/parts/render.rs deleted file mode 100644 index 6aa2685f..00000000 --- a/crates/ui/src/tabs/file_editor/parts/render.rs +++ /dev/null @@ -1,255 +0,0 @@ -pub fn render(tab: &Tab, window: &mut Window, cx: &mut Context) -> AnyElement { - let Some(path) = tab.path.clone() else { - return missing_path(cx); - }; - let theme = *cx.theme(); - let file_tree_root = cx - .file_tree() - .cloned() - .and_then(|tree| tree.read(cx).root().map(Path::to_path_buf)); - let breadcrumb = render_breadcrumb(&path, file_tree_root.as_deref(), theme); - let buffer = BufferStore::open(path.clone(), cx); - let editor_state = - ComponentEditorStore::state_for_tab(tab.id, &buffer, file_tree_root.clone(), window, cx); - let input = editor_state.input; - if let Some(reveal) = ComponentEditorStore::take_pending_reveal(&path, cx) { - let value = input.read(cx).value().to_string(); - let lines = value.split('\n').collect::>(); - let line = reveal - .line - .saturating_sub(1) - .min(lines.len().saturating_sub(1)); - let column = reveal.column.min( - lines - .get(line) - .map(|line| line.chars().count()) - .unwrap_or(0), - ); - input.update(cx, |input, cx| { - input.set_cursor_position( - gpui_component::input::Position::new(line as u32, column as u32), - window, - cx, - ); - }); - } - let completions = editor_state.completions; - let color_picker = editor_state.color_picker; - let input_focus = input.focus_handle(cx); - let input_for_copy = input.clone(); - let input_for_cut = input.clone(); - let input_for_auto_pair = input.clone(); - - div() - .size_full() - .min_h_0() - .min_w_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .text_color(theme.text) - .child(breadcrumb) - .child( - div() - .flex_1() - .min_h_0() - .min_w_0() - .track_focus(&input_focus) - .capture_action(cx.listener({ - let completions = completions.clone(); - move |_, _: &gpui_component::input::Enter, window, cx| { - let handled = - completions.update(cx, |menu, cx| menu.accept_selected(window, cx)); - if handled { - cx.stop_propagation(); - } else { - cx.propagate(); - } - } - })) - .capture_action(cx.listener({ - let completions = completions.clone(); - move |_, _: &gpui_component::input::IndentInline, window, cx| { - let handled = - completions.update(cx, |menu, cx| menu.accept_selected(window, cx)); - if handled { - cx.stop_propagation(); - } else { - cx.propagate(); - } - } - })) - .capture_action(cx.listener({ - let completions = completions.clone(); - move |_, _: &gpui_component::input::Escape, _, cx| { - let handled = completions.update(cx, |menu, cx| menu.hide(cx)); - if handled { - cx.stop_propagation(); - } else { - cx.propagate(); - } - } - })) - .capture_action(cx.listener({ - let completions = completions.clone(); - move |_, _: &gpui_component::input::MoveUp, _, cx| { - let handled = completions.update(cx, |menu, cx| menu.select_previous(cx)); - if handled { - cx.stop_propagation(); - } else { - cx.propagate(); - } - } - })) - .capture_action(cx.listener({ - let completions = completions.clone(); - move |_, _: &gpui_component::input::MoveDown, _, cx| { - let handled = completions.update(cx, |menu, cx| menu.select_next(cx)); - if handled { - cx.stop_propagation(); - } else { - cx.propagate(); - } - } - })) - .on_action( - cx.listener(move |_, _: &gpui_component::input::Copy, _, cx| { - copy_current_component_line(&input_for_copy, cx); - }), - ) - .capture_action(cx.listener( - move |_, _: &gpui_component::input::Cut, window, cx| { - if cut_current_component_line(&input_for_cut, window, cx) { - cx.stop_propagation(); - } else { - cx.propagate(); - } - }, - )) - .capture_key_down( - move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| { - if insert_component_auto_pair(&input_for_auto_pair, event, window, cx) { - cx.stop_propagation(); - } - }, - ) - .child( - div() - .id("file-editor-input-overlay") - .relative() - .size_full() - .child( - Input::new(&input) - .appearance(false) - .bordered(false) - .focus_bordered(false) - .font_family(FONT_FAMILY) - .text_size(rems(0.875)) - .size_full(), - ) - .child(completions) - .child(color_picker), - ), - ) - .into_any_element() -} - -fn render_breadcrumb(path: &Path, root: Option<&Path>, theme: Theme) -> AnyElement { - let segments = breadcrumb_segments(path, root); - if segments.is_empty() { - return div().flex_none().into_any_element(); - } - let last_idx = segments.len() - 1; - let file_icon = file_icon_for_path(path); - - let mut row = div() - .flex() - .flex_none() - .flex_row() - .items_center() - .w_full() - .min_w_0() - .px(rems(0.75)) - .py(rems(0.375)) - .gap(rems(0.25)) - .text_xs() - .text_color(theme.text_subtle) - .overflow_hidden() - .whitespace_nowrap(); - - for (i, seg) in segments.into_iter().enumerate() { - if i > 0 { - row = row.child( - Icon::new(IconName::ChevronRight) - .size(12.0) - .color(theme.text_subtle), - ); - } - if i == last_idx { - row = row.child( - div() - .flex() - .flex_row() - .items_center() - .gap(rems(0.25)) - .child(Icon::new(file_icon).size(14.0).color(theme.text_muted)) - .child(div().text_color(theme.text_muted).child(seg)), - ); - } else { - row = row.child(div().child(seg)); - } - } - - row.into_any_element() -} - -fn breadcrumb_segments(path: &Path, root: Option<&Path>) -> Vec { - if let Some(root) = root - && let Ok(relative) = path.strip_prefix(root) - { - return relative - .components() - .filter_map(|c| match c { - std::path::Component::Normal(s) => { - s.to_str().map(|s| SharedString::from(s.to_string())) - } - _ => None, - }) - .collect(); - } - path.file_name() - .and_then(|n| n.to_str()) - .map(|s| vec![SharedString::from(s.to_string())]) - .unwrap_or_default() -} - -fn file_icon_for_path(path: &Path) -> IconName { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && let Some(icon) = IconName::for_file_name(name) - { - return icon; - } - language::from_path(path) - .and_then(|id| IconName::for_language(id.as_str())) - .unwrap_or(IconName::File) -} - -fn missing_path(cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .text_color(theme.text_subtle) - .child( - Icon::new(super::icon_for_kind(registry::FILE_EDITOR.id)) - .size(32.0) - .color(theme.text_muted), - ) - .child(div().text_sm().child("No file")) - .into_any_element() -} diff --git a/crates/ui/src/tabs/file_editor/parts/tests.rs b/crates/ui/src/tabs/file_editor/parts/tests.rs deleted file mode 100644 index 7508db9e..00000000 --- a/crates/ui/src/tabs/file_editor/parts/tests.rs +++ /dev/null @@ -1,211 +0,0 @@ -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn component_line_copy_range_includes_trailing_newline() { - assert_eq!(component_line_range_for_offset("one\ntwo\nthree", 5), 4..8); - } - - #[test] - fn component_line_range_covers_current_line_for_cut() { - assert_eq!(component_line_range_for_offset("one\ntwo\nthree", 5), 4..8); - assert_eq!(component_line_range_for_offset("one\ntwo\nthree", 9), 8..13); - } - - #[test] - fn component_auto_pair_maps_opening_delimiters() { - assert_eq!(component_auto_pair("("), Some(('(', ')'))); - assert_eq!(component_auto_pair("["), Some(('[', ']'))); - assert_eq!(component_auto_pair("{"), Some(('{', '}'))); - assert_eq!(component_auto_pair("\""), Some(('\"', '\"'))); - assert_eq!(component_auto_pair("'"), Some(('\'', '\''))); - assert_eq!(component_auto_pair("`"), Some(('`', '`'))); - assert_eq!(component_auto_pair(")"), None); - } - - #[test] - fn component_utf16_range_counts_wide_characters() { - assert_eq!(component_range_to_utf16("a💝b", &(0..5)), 0..3); - } - - #[test] - fn component_completion_query_uses_identifier_suffix() { - assert_eq!(completion_filter_query(".pri"), "pri"); - assert_eq!(completion_filter_query("PROFILE.pri"), "pri"); - assert_eq!(completion_filter_query("::pri"), "pri"); - assert_eq!(completion_filter_query("background-co"), "background-co"); - } - - #[test] - fn component_completion_raw_query_uses_current_expression() { - let content = "const test = PROFILE.na"; - assert_eq!( - completion_raw_query_for_offset(content, content.len()), - "PROFILE.na" - ); - } - - #[test] - fn component_completion_line_for_offset_tracks_document_line() { - assert_eq!(completion_line_for_offset("one\ntwo\nthree", 0), 0); - assert_eq!(completion_line_for_offset("one\ntwo\nthree", 5), 1); - assert_eq!(completion_line_for_offset("one\ntwo\nthree", 9), 2); - } - - #[test] - fn component_completion_hides_after_query_terminator() { - assert!(completion_should_request(".description")); - assert!(completion_should_request(".")); - assert!(!completion_should_request(".description,")); - assert!(!completion_should_request(" ")); - } - - #[test] - fn component_completion_ranking_tracks_query() { - let mut items = vec![ - completion_item("zoo"), - completion_item("apricot"), - completion_item("std::print"), - completion_item("print"), - ]; - - rank_completion_items(&mut items, "pri"); - - let labels = items.into_iter().map(|item| item.label).collect::>(); - assert_eq!(labels, ["print", "std::print", "apricot", "zoo"]); - } - - #[test] - fn component_completion_adds_compact_detail_and_full_documentation() { - let mut item = completion_item("promise"); - item.kind = Some(CompletionItemKind::FUNCTION); - item.detail = Some( - "astro:src/content/loaders/glob promise helper with a very long source path" - .to_string(), - ); - - enhance_completion_item(&mut item); - let entry = ComponentCompletionItem::new(item.clone()); - - assert_eq!(entry.label, "promise"); - assert_eq!(entry.detail.as_deref(), Some("(function)")); - match item.documentation { - Some(Documentation::String(documentation)) => { - assert!(documentation.contains("astro:src/content/loaders/glob")); - } - _ => panic!("expected full detail documentation"), - } - } - - #[test] - fn component_completion_keeps_useful_short_detail() { - let mut item = completion_item("name"); - item.kind = Some(CompletionItemKind::PROPERTY); - item.detail = Some("(property) name: string".to_string()); - - enhance_completion_item(&mut item); - let entry = ComponentCompletionItem::new(item.clone()); - - assert_eq!(entry.label, "name"); - assert_eq!(entry.detail.as_deref(), Some("(property) name: string")); - match item.documentation { - Some(Documentation::String(documentation)) => { - assert_eq!(documentation, "(property) name: string"); - } - _ => panic!("expected detail documentation"), - } - } - - #[test] - fn component_completion_avoids_kind_only_documentation() { - let mut item = completion_item("Promise"); - item.kind = Some(CompletionItemKind::CLASS); - - enhance_completion_item(&mut item); - let entry = ComponentCompletionItem::new(item.clone()); - - assert_eq!(entry.label, "Promise"); - assert_eq!(entry.detail.as_deref(), Some("(class)")); - assert!(item.documentation.is_none()); - } - - #[test] - fn component_completion_fallback_edit_inserts_label() { - let item = completion_item("name"); - let (range, new_text) = completion_edit_for_item(&item, "PROFILE.n", 8..9); - - assert_eq!(range, 8..9); - assert_eq!(new_text, "name"); - } - - #[test] - fn component_completion_text_edit_uses_server_range() { - let mut item = completion_item("name"); - item.kind = Some(CompletionItemKind::FIELD); - item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit { - range: test_completion_range(), - new_text: "name".to_string(), - })); - - let (range, new_text) = completion_edit_for_item(&item, " PROF", 0..0); - - assert_eq!(range, 2..5); - assert_eq!(new_text, "name"); - } - - #[test] - fn component_css_colors_detect_common_css_literals() { - let content = "a{color:#abc;background:rgb(255 0 0 / 50%);border:rgba(1, 2, 3, .4);outline:oklch(63.7% 0.237 25.331)}"; - let colors = component_css_colors(content); - - let literals = colors - .iter() - .map(|color| &content[color.range.clone()]) - .collect::>(); - - assert_eq!( - literals, - [ - "#abc", - "rgb(255 0 0 / 50%)", - "rgba(1, 2, 3, .4)", - "oklch(63.7% 0.237 25.331)" - ] - ); - assert!((colors[1].color.a - 0.5).abs() < 0.001); - assert!((colors[2].color.a - 0.4).abs() < 0.001); - } - - #[test] - fn component_css_color_formatter_preserves_function_family() { - let color = gpui::hsla(0.5, 0.5, 0.5, 0.25); - - assert_eq!( - component_css_format_color(&ComponentCssColorFormat::Rgb { alpha: false }, color), - "rgba(64, 191, 191, 0.25)" - ); - assert_eq!( - component_css_format_color(&ComponentCssColorFormat::Hex { alpha: false }, color), - "#40BFBF40" - ); - assert!( - component_css_format_color(&ComponentCssColorFormat::Oklch { alpha: false }, color) - .starts_with("oklch(") - ); - } - - fn completion_item(label: &str) -> CompletionItem { - CompletionItem { - label: label.to_string(), - ..Default::default() - } - } - - fn test_completion_range() -> lsp_types::Range { - lsp_types::Range::new( - lsp_types::Position::new(0, 2), - lsp_types::Position::new(0, 5), - ) - } -} diff --git a/crates/ui/src/tabs/file_search/mod.rs b/crates/ui/src/tabs/file_search/mod.rs deleted file mode 100644 index 59707863..00000000 --- a/crates/ui/src/tabs/file_search/mod.rs +++ /dev/null @@ -1,1988 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - ops::Range, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, - time::Duration, -}; - -use gpui::{ - AnyElement, AppContext, Context, Entity, FocusHandle, FontWeight, Global, HighlightStyle, - IntoElement, KeyDownEvent, ListSizingBehavior, ScrollStrategy, SharedString, StyledText, - Window, div, point, prelude::*, px, rems, size, -}; -use gpui_component::{ - Icon as ComponentIcon, IconName as ComponentIconName, Selectable, Sizable, Size, - VirtualListScrollHandle, - alert::Alert, - button::{Button, ButtonGroup}, - input::{IndentInline, Input, InputEvent, InputState, OutdentInline}, - list::ListItem, - v_virtual_list, -}; -use icons::{Icon, IconName}; -use settings::{ActiveSettings, SettingValue}; - -use tabs::registry; -use theme::ActiveTheme; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; - -const INITIAL_RESULT_LIMIT: usize = 50; -const RESULT_LIMIT_INCREMENT: usize = 100; -const LOAD_MORE_THRESHOLD: usize = 10; -const RESULT_ROW_HEIGHT_REM: f32 = 3.125; -const CONTENT_RESULT_ROW_HEIGHT_REM: f32 = 3.125; -const SEARCH_DEBOUNCE: Duration = Duration::from_millis(80); -const SCAN_REFRESH_INTERVAL: Duration = Duration::from_millis(200); -const PREVIEW_FALLBACK_CENTER_OFFSET: usize = 18; -const PREVIEW_CONTEXT_LINES: usize = 64; -const PREVIEW_REUSE_MARGIN_LINES: usize = 16; -const PREVIEW_PREFETCH_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; -const PREVIEW_PREFETCH_TOTAL_BYTES: u64 = 64 * 1024 * 1024; -const FONT_FAMILY: &str = "DejaVu Sans Mono"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SearchMode { - Name, - Content, -} - -impl SearchMode { - fn toggled(self) -> Self { - match self { - Self::Name => Self::Content, - Self::Content => Self::Name, - } - } - - fn from_index(index: usize) -> Self { - if index == 1 { - Self::Content - } else { - Self::Name - } - } - - fn loading_label(self) -> &'static str { - match self { - Self::Name => "Searching files...", - Self::Content => "Searching contents...", - } - } -} - -#[derive(Clone)] -enum SearchSnapshot { - Name(Arc<::file_search::FileSearchSnapshot>), - Content(Arc<::file_search::ContentSearchSnapshot>), -} - -impl SearchSnapshot { - fn query(&self) -> &str { - match self { - Self::Name(snapshot) => &snapshot.query, - Self::Content(snapshot) => &snapshot.query, - } - } - - fn is_scanning(&self) -> bool { - match self { - Self::Name(snapshot) => snapshot.is_scanning, - Self::Content(snapshot) => snapshot.is_scanning, - } - } - - fn result_count(&self) -> usize { - match self { - Self::Name(snapshot) => snapshot.results.len(), - Self::Content(snapshot) => snapshot.results.len(), - } - } - - fn can_load_more(&self, current_limit: usize) -> bool { - if self.query().trim().is_empty() { - return false; - } - - match self { - Self::Name(snapshot) => snapshot.results.len() < snapshot.total_matched, - Self::Content(snapshot) => snapshot.results.len() >= current_limit, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PreviewTarget { - path: PathBuf, - line_number: u64, - column: usize, -} - -#[derive(Clone)] -struct PreviewFile { - content: Arc, - language: String, - line_starts: Arc<[usize]>, -} - -#[derive(Clone)] -struct PreviewRequest { - target: PreviewTarget, - generation: u64, -} - -#[derive(Clone)] -struct PreviewPrefetchFile { - path: PathBuf, -} - -#[derive(Clone)] -struct PreviewPrefetchRequest { - generation: u64, - files: Vec, -} - -struct PreviewFileData { - path: PathBuf, - file: PreviewFile, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PreviewWindow { - path: PathBuf, - start_line: usize, - end_line: usize, - trailing_padding: usize, -} - -#[derive(Clone)] -struct PreviewUpdate { - input: Entity, - content: Option>, - language: String, - line_number_base: usize, - cursor_line: usize, - column: usize, - language_changed: bool, -} - -pub struct FileSearchUi { - input: Entity, - preview_input: Entity, - preview_soft_wrap: bool, - focus_handle: FocusHandle, - indices: HashMap>, - active_root: Option, - active_index: Option>, - mode: SearchMode, - query: String, - snapshot: Option, - error: Option, - loading: bool, - search_running: bool, - loading_more: bool, - selected_result: usize, - result_limit: usize, - generation: u64, - refresh_scheduled: bool, - scroll_handle: VirtualListScrollHandle, - preview_target: Option, - preview_generation: u64, - preview_loading: bool, - preview_content: Option, - preview_language: String, - preview_error: Option, - preview_dirty: bool, - preview_cache: HashMap, - preview_input_file_content: Option>, - preview_input_window: Option, - preview_input_language: String, - preview_prefetch_generation: u64, -} - -#[derive(Clone)] -struct FileSearchView { - index: Option>, - focus_handle: FocusHandle, - mode: SearchMode, - query: String, - snapshot: Option, - error: Option, - loading: bool, - selected_result: usize, - scroll_handle: VirtualListScrollHandle, - preview_input: Entity, - preview_target: Option, - preview_loading: bool, - preview_error: Option, -} - -#[derive(Clone)] -struct SearchRequest { - index: Arc<::file_search::FileSearchIndex>, - mode: SearchMode, - query: String, - generation: u64, - limit: usize, - kind: SearchKind, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SearchKind { - Initial, - Refresh, - LoadMore, -} - -enum SearchResult { - Name(::file_search::FileSearchSnapshot), - Content(::file_search::ContentSearchSnapshot), -} - -struct OpenTarget { - index: Arc<::file_search::FileSearchIndex>, - query: String, - path: PathBuf, - line: Option, - column: Option, -} - -impl FileSearchUi { - pub fn install(window: &mut Window, cx: &mut Context) { - if cx.try_global::().is_some() { - return; - } - - let input = - cx.new(|cx| InputState::new(window, cx).placeholder("Search files by name or path")); - let preview_soft_wrap = preview_soft_wrap_enabled(cx); - let preview_input = cx.new(|cx| { - InputState::new(window, cx) - .code_editor("plaintext") - .multi_line(true) - .line_number(true) - .soft_wrap(preview_soft_wrap) - }); - cx.subscribe(&input, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }) - .detach(); - - cx.set_global(Self { - input, - preview_input, - preview_soft_wrap, - focus_handle: cx.focus_handle().tab_stop(true), - indices: HashMap::new(), - active_root: None, - active_index: None, - mode: SearchMode::Name, - query: String::new(), - snapshot: None, - error: None, - loading: false, - search_running: false, - loading_more: false, - selected_result: 0, - result_limit: INITIAL_RESULT_LIMIT, - generation: 0, - refresh_scheduled: false, - scroll_handle: VirtualListScrollHandle::new(), - preview_target: None, - preview_generation: 0, - preview_loading: false, - preview_content: None, - preview_language: "plaintext".to_string(), - preview_error: None, - preview_dirty: false, - preview_cache: HashMap::new(), - preview_input_file_content: None, - preview_input_window: None, - preview_input_language: "plaintext".to_string(), - preview_prefetch_generation: 0, - }); - } - - fn input(&self) -> Entity { - self.input.clone() - } - - fn sync_preview_soft_wrap(&mut self, soft_wrap: bool) -> Option> { - if self.preview_soft_wrap == soft_wrap { - return None; - } - - self.preview_soft_wrap = soft_wrap; - Some(self.preview_input.clone()) - } - - fn mode(&self) -> SearchMode { - self.mode - } - - fn index_for( - &mut self, - root: PathBuf, - ) -> Result, ::file_search::Error> { - if let Some(index) = self.indices.get(&root) { - return Ok(index.clone()); - } - - let index = Arc::new(::file_search::FileSearchIndex::new(root.clone())?); - self.indices.insert(root, index.clone()); - Ok(index) - } - - fn prepare_search(&mut self, root: PathBuf, query: String) -> Option { - let root_changed = self.active_root.as_ref() != Some(&root); - let query_changed = self.query != query; - if !root_changed - && !query_changed - && (self.loading || self.snapshot.is_some() || self.error.is_some()) - { - return None; - } - - let index = match self.index_for(root.clone()) { - Ok(index) => index, - Err(error) => { - self.active_root = Some(root); - self.active_index = None; - self.query = query; - self.snapshot = None; - self.error = Some(error.to_string()); - self.loading = false; - self.loading_more = false; - return None; - } - }; - - self.generation = self.generation.wrapping_add(1); - self.active_root = Some(root); - self.active_index = Some(index.clone()); - self.query = query.clone(); - self.snapshot = None; - self.error = None; - self.loading = true; - self.loading_more = false; - self.refresh_scheduled = false; - if root_changed || query_changed { - self.selected_result = 0; - self.result_limit = INITIAL_RESULT_LIMIT; - self.scroll_handle = VirtualListScrollHandle::new(); - self.clear_preview(); - self.preview_cache.clear(); - } - - Some(SearchRequest { - index, - mode: self.mode, - query, - generation: self.generation, - limit: self.result_limit, - kind: SearchKind::Initial, - }) - } - - fn set_mode(&mut self, mode: SearchMode) { - if self.mode == mode { - return; - } - - self.mode = mode; - self.generation = self.generation.wrapping_add(1); - self.snapshot = None; - self.error = None; - self.loading = false; - self.loading_more = false; - self.selected_result = 0; - self.result_limit = INITIAL_RESULT_LIMIT; - self.scroll_handle = VirtualListScrollHandle::new(); - self.refresh_scheduled = false; - self.clear_preview(); - self.preview_cache.clear(); - } - - fn toggle_mode(&mut self) { - self.set_mode(self.mode.toggled()); - } - - fn view(&self) -> FileSearchView { - FileSearchView { - index: self.active_index.clone(), - focus_handle: self.focus_handle.clone(), - mode: self.mode, - query: self.query.clone(), - snapshot: self.snapshot.clone(), - error: self.error.clone(), - loading: self.loading, - selected_result: self.selected_result, - scroll_handle: self.scroll_handle.clone(), - preview_input: self.preview_input.clone(), - preview_target: self.preview_target.clone(), - preview_loading: self.preview_loading, - preview_error: self.preview_error.clone(), - } - } - - fn selected_open_target(&self) -> Option { - let index = self.active_index.clone()?; - let snapshot = self.snapshot.as_ref()?; - match snapshot { - SearchSnapshot::Name(snapshot) => { - let result = snapshot.results.get(self.selected_result)?; - Some(OpenTarget { - index, - query: snapshot.query.clone(), - path: result.absolute_path.clone(), - line: None, - column: None, - }) - } - SearchSnapshot::Content(snapshot) => { - let result = snapshot.results.get(self.selected_result)?; - Some(OpenTarget { - index, - query: snapshot.query.clone(), - path: result.absolute_path.clone(), - line: Some(result.line_number as usize), - column: Some(byte_column_to_char_column( - &result.line_content, - result.column, - )), - }) - } - } - } - - fn move_selection(&mut self, direction: isize) -> bool { - let Some(snapshot) = self.snapshot.as_ref() else { - return false; - }; - if snapshot.result_count() == 0 { - return false; - } - - let last = snapshot.result_count() - 1; - let next = if direction < 0 { - self.selected_result.saturating_sub(1) - } else { - (self.selected_result + 1).min(last) - }; - if next == self.selected_result { - return false; - } - - self.select_result(next, true) - } - - fn select_result(&mut self, result: usize, scroll_to_result: bool) -> bool { - let Some(snapshot) = self.snapshot.as_ref() else { - return false; - }; - if result >= snapshot.result_count() || result == self.selected_result { - return false; - } - - self.selected_result = result; - if scroll_to_result { - self.scroll_handle - .scroll_to_item(self.selected_result, ScrollStrategy::Nearest); - } - self.invalidate_preview_for_navigation(); - true - } - - fn begin_search(&mut self, generation: u64, kind: SearchKind) -> bool { - if self.generation != generation || self.search_running { - match kind { - SearchKind::Refresh => self.refresh_scheduled = false, - SearchKind::LoadMore => self.loading_more = false, - SearchKind::Initial => {} - } - return false; - } - - self.search_running = true; - true - } - - fn finish_search( - &mut self, - generation: u64, - result: Result, - kind: SearchKind, - ) -> Option { - self.search_running = false; - match kind { - SearchKind::Refresh => self.refresh_scheduled = false, - SearchKind::LoadMore => self.loading_more = false, - SearchKind::Initial => {} - } - - if self.generation != generation { - return self.active_index.clone().map(|index| SearchRequest { - index, - mode: self.mode, - query: self.query.clone(), - generation: self.generation, - limit: self.result_limit, - kind: SearchKind::Initial, - }); - } - - self.loading = false; - match result { - Ok(SearchResult::Name(snapshot)) => { - self.selected_result = self - .selected_result - .min(snapshot.results.len().saturating_sub(1)); - self.snapshot = Some(SearchSnapshot::Name(Arc::new(snapshot))); - if kind != SearchKind::LoadMore { - self.clear_preview(); - } - self.preview_cache.clear(); - self.error = None; - } - Ok(SearchResult::Content(snapshot)) => { - self.selected_result = self - .selected_result - .min(snapshot.results.len().saturating_sub(1)); - self.retain_preview_cache_for_results(&snapshot.results); - self.snapshot = Some(SearchSnapshot::Content(Arc::new(snapshot))); - if kind != SearchKind::LoadMore { - self.clear_preview(); - } - self.error = None; - } - Err(error) => { - if kind != SearchKind::LoadMore { - self.snapshot = None; - } - self.error = Some(error); - } - } - - None - } - - fn prepare_load_more(&mut self, visible_end: usize) -> Option { - if self.loading || self.loading_more || self.search_running || self.error.is_some() { - return None; - } - - let snapshot = self.snapshot.as_ref()?; - let result_count = snapshot.result_count(); - if result_count == 0 || visible_end.saturating_add(LOAD_MORE_THRESHOLD) < result_count { - return None; - } - if !snapshot.can_load_more(self.result_limit) { - return None; - } - - let index = self.active_index.clone()?; - self.result_limit = self.result_limit.saturating_add(RESULT_LIMIT_INCREMENT); - self.loading_more = true; - - Some(SearchRequest { - index, - mode: self.mode, - query: self.query.clone(), - generation: self.generation, - limit: self.result_limit, - kind: SearchKind::LoadMore, - }) - } - - fn selected_preview_target(&self) -> Option { - let Some(SearchSnapshot::Content(snapshot)) = self.snapshot.as_ref() else { - return None; - }; - let result = snapshot.results.get(self.selected_result)?; - Some(PreviewTarget { - path: result.absolute_path.clone(), - line_number: result.line_number, - column: byte_column_to_char_column(&result.line_content, result.column), - }) - } - - fn prepare_preview(&mut self) -> Option { - let target = self.selected_preview_target()?; - if self.preview_target.as_ref() == Some(&target) - && (self.preview_loading - || self.preview_content.is_some() - || self.preview_error.is_some()) - { - return None; - } - - let cached_file = self.preview_cache.get(&target.path).cloned(); - self.preview_generation = self.preview_generation.wrapping_add(1); - self.preview_target = Some(target.clone()); - self.preview_error = None; - - if let Some(file) = cached_file { - self.preview_loading = false; - self.preview_language = file.language.clone(); - self.preview_content = Some(file); - self.preview_dirty = true; - return None; - } - - self.preview_loading = true; - self.preview_content = None; - self.preview_dirty = false; - - Some(PreviewRequest { - target, - generation: self.preview_generation, - }) - } - - fn prepare_preview_prefetch(&mut self) -> Option { - if self.preview_prefetch_generation == self.generation { - return None; - } - - let Some(SearchSnapshot::Content(snapshot)) = self.snapshot.as_ref() else { - return None; - }; - if snapshot.results.is_empty() { - return None; - } - - self.preview_prefetch_generation = self.generation; - - let mut seen = HashSet::new(); - let mut files = Vec::new(); - let mut total_bytes = 0_u64; - let selected = self.selected_result.min(snapshot.results.len() - 1); - - for ix in std::iter::once(selected).chain(0..snapshot.results.len()) { - let result = &snapshot.results[ix]; - if result.is_binary - || result.size > PREVIEW_PREFETCH_MAX_FILE_BYTES - || total_bytes.saturating_add(result.size) > PREVIEW_PREFETCH_TOTAL_BYTES - || self.preview_cache.contains_key(&result.absolute_path) - || !seen.insert(result.absolute_path.clone()) - { - continue; - } - - total_bytes = total_bytes.saturating_add(result.size); - files.push(PreviewPrefetchFile { - path: result.absolute_path.clone(), - }); - } - - if files.is_empty() { - return None; - } - - Some(PreviewPrefetchRequest { - generation: self.generation, - files, - }) - } - - fn finish_preview(&mut self, generation: u64, result: Result) -> bool { - if self.preview_generation != generation { - return false; - } - - self.preview_loading = false; - match result { - Ok(file) => { - if let Some(target) = self.preview_target.as_ref() { - self.preview_cache.insert(target.path.clone(), file.clone()); - } - self.preview_language = file.language.clone(); - self.preview_content = Some(file); - self.preview_error = None; - self.preview_dirty = true; - } - Err(error) => { - self.preview_content = None; - self.preview_error = Some(error); - self.preview_dirty = false; - } - } - true - } - - fn finish_preview_prefetch(&mut self, generation: u64, files: Vec) -> bool { - if self.generation != generation { - return false; - } - - let Some(SearchSnapshot::Content(snapshot)) = self.snapshot.as_ref() else { - return false; - }; - let result_paths = snapshot - .results - .iter() - .map(|result| result.absolute_path.clone()) - .collect::>(); - let mut current_preview = None; - - for file in files { - if !result_paths.contains(&file.path) { - continue; - } - - let entry = PreviewFile { - content: file.file.content, - language: file.file.language, - line_starts: file.file.line_starts, - }; - if self - .preview_target - .as_ref() - .is_some_and(|target| target.path == file.path) - { - current_preview = Some(entry.clone()); - } - self.preview_cache.insert(file.path, entry); - } - - let Some(file) = current_preview else { - return false; - }; - if !self.preview_loading && self.preview_content.is_some() { - return false; - } - - self.preview_loading = false; - self.preview_language = file.language.clone(); - self.preview_content = Some(file); - self.preview_error = None; - self.preview_dirty = true; - true - } - - fn take_preview_update(&mut self) -> Option { - if !self.preview_dirty { - return None; - } - let target = self.preview_target.as_ref()?; - let file = self.preview_content.clone()?; - let line_count = file.line_starts.len().max(1); - let target_line = - (target.line_number.saturating_sub(1) as usize).min(line_count.saturating_sub(1)); - - if let Some(window) = self - .reusable_preview_window(target, &file, target_line) - .cloned() - { - self.preview_dirty = false; - return Some(PreviewUpdate { - input: self.preview_input.clone(), - content: None, - language: file.language, - line_number_base: window.start_line, - cursor_line: target_line.saturating_sub(window.start_line), - column: target.column, - language_changed: false, - }); - } - - let start_line = target_line.saturating_sub(PREVIEW_CONTEXT_LINES); - let end_line = target_line - .saturating_add(PREVIEW_CONTEXT_LINES + 1) - .min(line_count); - let trailing_padding = if end_line == line_count { - PREVIEW_CONTEXT_LINES - } else { - 0 - }; - let content = preview_slice(&file, start_line, end_line, trailing_padding); - let language_changed = self.preview_input_language != file.language; - let window = PreviewWindow { - path: target.path.clone(), - start_line, - end_line, - trailing_padding, - }; - - self.preview_dirty = false; - self.preview_input_file_content = Some(file.content.clone()); - self.preview_input_window = Some(window); - self.preview_input_language = file.language.clone(); - - Some(PreviewUpdate { - input: self.preview_input.clone(), - content: Some(content), - language: file.language, - line_number_base: start_line, - cursor_line: target_line.saturating_sub(start_line), - column: target.column, - language_changed, - }) - } - - fn reusable_preview_window( - &self, - target: &PreviewTarget, - file: &PreviewFile, - target_line: usize, - ) -> Option<&PreviewWindow> { - if self.preview_input_language != file.language - || !self - .preview_input_file_content - .as_ref() - .is_some_and(|current| Arc::ptr_eq(current, &file.content)) - { - return None; - } - - let window = self.preview_input_window.as_ref()?; - if window.path != target.path - || target_line < window.start_line - || target_line >= window.end_line - { - return None; - } - - let line_count = file.line_starts.len().max(1); - let before = target_line.saturating_sub(window.start_line); - let after = window - .end_line - .saturating_sub(target_line.saturating_add(1)) - .saturating_add(window.trailing_padding); - let before_ok = window.start_line == 0 || before >= PREVIEW_REUSE_MARGIN_LINES; - let after_ok = window.end_line == line_count || after >= PREVIEW_REUSE_MARGIN_LINES; - if before_ok && after_ok { - Some(window) - } else { - None - } - } - - fn clear_preview(&mut self) { - self.preview_generation = self.preview_generation.wrapping_add(1); - self.preview_target = None; - self.preview_loading = false; - self.preview_content = None; - self.preview_error = None; - self.preview_dirty = false; - } - - fn invalidate_preview_for_navigation(&mut self) { - self.preview_generation = self.preview_generation.wrapping_add(1); - self.preview_loading = false; - self.preview_error = None; - self.preview_dirty = false; - } - - fn retain_preview_cache_for_results(&mut self, results: &[::file_search::ContentSearchResult]) { - let result_paths = results - .iter() - .map(|result| result.absolute_path.clone()) - .collect::>(); - self.preview_cache - .retain(|path, _| result_paths.contains(path)); - } -} - -impl Global for FileSearchUi {} - -pub fn render( - workspace_path: &Path, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - FileSearchUi::install(window, cx); - let theme = *cx.theme(); - let input = cx.global::().input(); - let mode = cx.global::().mode(); - sync_preview_soft_wrap(window, cx); - let query = input.read(cx).value().trim().to_string(); - let root = workspace_path.to_path_buf(); - if let Some(request) = - cx.update_global::(|state, _| state.prepare_search(root, query)) - { - spawn_search(request, SEARCH_DEBOUNCE, cx); - } - if matches!(mode, SearchMode::Content) { - schedule_preview_prefetch(cx); - schedule_preview_load(cx); - sync_preview_input(window, cx); - } - - let view = cx.global::().view(); - - if view.query.is_empty() - && view - .snapshot - .as_ref() - .is_some_and(SearchSnapshot::is_scanning) - { - schedule_scan_refresh(cx); - } - let focus_handle = view.focus_handle.clone(); - - div() - .relative() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .text_color(theme.text) - .track_focus(&focus_handle) - .capture_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { - match event.keystroke.key.as_str() { - "tab" if !event.keystroke.modifiers.control && !event.keystroke.modifiers.alt => { - cx.stop_propagation(); - toggle_search_mode(cx); - } - "up" => { - cx.stop_propagation(); - cx.update_global::(|state, _| { - state.move_selection(-1); - }); - cx.notify(); - } - "down" => { - cx.stop_propagation(); - cx.update_global::(|state, _| { - state.move_selection(1); - }); - cx.notify(); - } - "enter" => { - let Some(target) = cx.global::().selected_open_target() else { - return; - }; - cx.stop_propagation(); - target.index.track_open(&target.query, &target.path); - if let Some(line) = target.line { - this.open_file_at(target.path, line, target.column.unwrap_or(0), cx); - } else { - this.open_file(target.path, cx); - } - } - _ => {} - } - })) - .capture_action(cx.listener(|_, _: &IndentInline, _, cx| { - cx.stop_propagation(); - toggle_search_mode(cx); - })) - .capture_action(cx.listener(|_, _: &OutdentInline, _, cx| { - cx.stop_propagation(); - toggle_search_mode(cx); - })) - .child(header(&input, mode, cx)) - .when_some(view.error.clone(), |this, error| { - this.child(error_banner(error, cx)) - }) - .child(results(&view, focus_handle, window, cx)) - .into_any_element() -} - -fn toggle_search_mode(cx: &mut Context) { - cx.update_global::(|state, _| state.toggle_mode()); - cx.notify(); -} - -fn header( - input: &Entity, - mode: SearchMode, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - - div() - .flex_none() - .border_b_1() - .border_color(theme.border_subtle) - .p(rems(1.5)) - .child( - div() - .flex() - .w_full() - .items_center() - .gap_3() - .child( - div().flex_1().min_w_0().child( - Input::new(input) - .bordered(false) - .prefix( - ComponentIcon::new(ComponentIconName::Search) - .small() - .text_color(gpui::Hsla::from(theme.text_muted)), - ) - .cleanable(true) - .w_full(), - ), - ) - .child(search_mode_switch(mode)), - ) - .into_any_element() -} - -fn search_mode_switch(mode: SearchMode) -> AnyElement { - ButtonGroup::new("file-search-mode") - .compact() - .outline() - .with_size(Size::Medium) - .child( - Button::new("file-search-mode-name") - .label("Name") - .selected(mode == SearchMode::Name), - ) - .child( - Button::new("file-search-mode-content") - .label("Content") - .selected(mode == SearchMode::Content), - ) - .on_click(|selected, _, cx| { - let mode = selected - .last() - .copied() - .map(SearchMode::from_index) - .unwrap_or(SearchMode::Name); - cx.update_global::(|state, _| state.set_mode(mode)); - cx.refresh_windows(); - }) - .into_any_element() -} - -fn error_banner( - error: String, - _cx: &mut Context, -) -> AnyElement { - Alert::error("file-search-error", SharedString::from(error)) - .banner() - .with_size(Size::Small) - .into_any_element() -} - -fn results( - view: &FileSearchView, - focus_handle: FocusHandle, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let Some(snapshot) = view.snapshot.as_ref() else { - if view.loading && view.query.is_empty() { - return centered_state("Preparing file search...", Some(focus_handle), cx); - } - - if view.loading { - return centered_state(view.mode.loading_label(), Some(focus_handle), cx); - } - - return centered_state( - "Unable to load the file search index", - Some(focus_handle), - cx, - ); - }; - - if snapshot.result_count() == 0 { - if snapshot.is_scanning() { - let message = match view.mode { - SearchMode::Name => "Indexing workspace files...", - SearchMode::Content => "Indexing workspace contents...", - }; - return centered_state(message, Some(focus_handle), cx); - } - - if snapshot.query().is_empty() { - let message = match view.mode { - SearchMode::Name => "Start typing to search workspace files", - SearchMode::Content => "Start typing to search file contents", - }; - return centered_state(message, Some(focus_handle), cx); - } - - return centered_state( - format!("No results found for \"{}\"", snapshot.query()), - Some(focus_handle), - cx, - ); - } - - match snapshot { - SearchSnapshot::Name(snapshot) => { - name_results(view, focus_handle, snapshot.clone(), window, cx) - } - SearchSnapshot::Content(snapshot) => { - content_results(view, focus_handle, snapshot.clone(), window, cx) - } - } -} - -fn name_results( - view: &FileSearchView, - focus_handle: FocusHandle, - snapshot: Arc<::file_search::FileSearchSnapshot>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let Some(index) = view.index.clone() else { - return centered_state( - "Unable to load the file search index", - Some(focus_handle), - cx, - ); - }; - - let rem_size = window.rem_size(); - let item_sizes = Rc::new( - (0..snapshot.results.len()) - .map(|_| size(px(0.0), rems(RESULT_ROW_HEIGHT_REM).to_pixels(rem_size))) - .collect::>(), - ); - let selected_result = view.selected_result; - let scroll_handle = view.scroll_handle.clone(); - - div() - .flex_1() - .min_h_0() - .track_focus(&focus_handle) - .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { - window.focus(&focus_handle, cx); - }) - .child( - div().size_full().child( - v_virtual_list(cx.entity().clone(), "file-search-results", item_sizes, { - move |_, range, _window, cx| { - schedule_load_more(range.end, cx); - range - .map(|ix| { - result_row( - ix, - index.clone(), - &snapshot.query, - snapshot.results[ix].clone(), - ix == selected_result, - cx, - ) - }) - .collect::>() - } - }) - .flex_grow() - .size_full() - .track_scroll(&scroll_handle) - .with_sizing_behavior(ListSizingBehavior::Auto), - ), - ) - .into_any_element() -} - -fn content_results( - view: &FileSearchView, - focus_handle: FocusHandle, - snapshot: Arc<::file_search::ContentSearchSnapshot>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let Some(index) = view.index.clone() else { - return centered_state( - "Unable to load the file search index", - Some(focus_handle), - cx, - ); - }; - - let rem_size = window.rem_size(); - let item_sizes = Rc::new( - (0..snapshot.results.len()) - .map(|_| { - size( - px(0.0), - rems(CONTENT_RESULT_ROW_HEIGHT_REM).to_pixels(rem_size), - ) - }) - .collect::>(), - ); - let selected_result = view.selected_result; - let scroll_handle = view.scroll_handle.clone(); - let results_snapshot = snapshot.clone(); - let theme = *cx.theme(); - - div() - .flex_1() - .min_h_0() - .flex() - .track_focus(&focus_handle) - .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { - window.focus(&focus_handle, cx); - }) - .child( - div().flex_1().min_w_0().size_full().child( - v_virtual_list(cx.entity().clone(), "content-search-results", item_sizes, { - move |_, range, _window, cx| { - schedule_load_more(range.end, cx); - range - .map(|ix| { - content_result_row( - ix, - index.clone(), - &results_snapshot.query, - results_snapshot.results[ix].clone(), - ix == selected_result, - cx, - ) - }) - .collect::>() - } - }) - .flex_grow() - .size_full() - .track_scroll(&scroll_handle) - .with_sizing_behavior(ListSizingBehavior::Auto), - ), - ) - .child( - div() - .flex_1() - .min_w_0() - .size_full() - .border_l_1() - .border_color(theme.border_subtle) - .child(content_preview(view, snapshot, window, cx)), - ) - .into_any_element() -} - -fn result_row( - ix: usize, - index: Arc<::file_search::FileSearchIndex>, - query: &str, - result: ::file_search::FileSearchResult, - is_selected: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let query_text = query.to_string(); - let query_for_click = query_text.clone(); - let path = result.absolute_path.clone(); - let icon = icon_for_result(&result); - let title = if result.name.is_empty() { - result.relative_path.clone() - } else { - result.name.clone() - }; - let metadata = format_size(result.size); - - ListItem::new(ix) - .w_full() - .h(rems(RESULT_ROW_HEIGHT_REM)) - .px(rems(1.5)) - .py_0() - .when(is_selected, |row| row.bg(theme.bg_selected)) - .on_mouse_enter(move |_, _, cx| { - let changed = - cx.update_global::(|state, _| state.select_result(ix, false)); - if changed { - cx.refresh_windows(); - } - }) - .child( - div() - .flex() - .items_center() - .gap_3() - .min_w_0() - .w_full() - .child( - div() - .flex_none() - .size(rems(1.75)) - .flex() - .items_center() - .justify_center() - .child(Icon::new(icon).size_rem(0.875).color(theme.text_muted)), - ) - .child( - div() - .min_w_0() - .flex_1() - .flex() - .flex_col() - .gap_0p5() - .child( - div() - .text_sm() - .text_color(theme.text_emphasis) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(highlighted_match_text(title, &query_text, theme.accent)), - ) - .child( - div() - .text_xs() - .text_color(theme.text_subtle) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(highlighted_match_text( - result.relative_path.clone(), - &query_text, - theme.accent, - )), - ), - ) - .child( - div() - .flex_none() - .text_xs() - .text_color(theme.text_subtle) - .child(metadata), - ), - ) - .on_click(cx.listener(move |this, _, _, cx| { - index.track_open(&query_for_click, &path); - this.open_file(path.clone(), cx); - })) - .into_any_element() -} - -fn content_result_row( - ix: usize, - index: Arc<::file_search::FileSearchIndex>, - query: &str, - result: ::file_search::ContentSearchResult, - is_selected: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let query_for_click = query.to_string(); - let path = result.absolute_path.clone(); - let icon = icon_for_content_result(&result); - let title = if result.name.is_empty() { - result.relative_path.clone() - } else { - result.name.clone() - }; - let column = byte_column_to_char_column(&result.line_content, result.column); - let metadata = format!("{}:{}", result.line_number, column + 1); - - ListItem::new(ix) - .w_full() - .h(rems(CONTENT_RESULT_ROW_HEIGHT_REM)) - .px(rems(1.5)) - .py_0() - .when(is_selected, |row| row.bg(theme.bg_selected)) - .on_mouse_enter(move |_, _, cx| { - let changed = - cx.update_global::(|state, _| state.select_result(ix, false)); - if changed { - cx.refresh_windows(); - } - }) - .child( - div() - .flex() - .items_center() - .gap_3() - .min_w_0() - .w_full() - .child( - div() - .flex_none() - .size(rems(1.75)) - .flex() - .items_center() - .justify_center() - .child(Icon::new(icon).size_rem(0.875).color(theme.text_muted)), - ) - .child( - div() - .min_w_0() - .flex_1() - .flex() - .flex_col() - .gap_0p5() - .child( - div() - .flex() - .items_center() - .gap_2() - .min_w_0() - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(theme.text_emphasis) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(highlighted_match_text( - title, - &query_for_click, - theme.accent, - )), - ) - .child( - div() - .flex_none() - .text_xs() - .text_color(theme.text_subtle) - .child(metadata), - ), - ) - .child( - div() - .text_xs() - .text_color(theme.text_subtle) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(result.relative_path.clone()), - ), - ), - ) - .on_click(cx.listener(move |this, _, _, cx| { - index.track_open(&query_for_click, &path); - this.open_file_at(path.clone(), result.line_number as usize, column, cx); - })) - .into_any_element() -} - -fn content_preview( - view: &FileSearchView, - snapshot: Arc<::file_search::ContentSearchSnapshot>, - _window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let selected = snapshot.results.get(view.selected_result); - let title = selected - .map(|result| format!("{}:{}", result.relative_path, result.line_number)) - .unwrap_or_else(|| "Preview".to_string()); - - div() - .size_full() - .min_w_0() - .min_h_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .child( - div() - .flex_none() - .h(rems(2.25)) - .px(rems(1.0)) - .flex() - .items_center() - .justify_between() - .border_b_1() - .border_color(theme.border_subtle) - .child( - div() - .min_w_0() - .text_xs() - .text_color(theme.text_subtle) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(title), - ) - .when(view.preview_loading, |this| { - this.child( - div() - .flex_none() - .text_xs() - .text_color(theme.text_muted) - .child("Loading..."), - ) - }), - ) - .when_some(view.preview_error.clone(), |this, error| { - this.child(error_banner(error, cx)) - }) - .when(view.preview_target.is_none(), |this| { - this.child(centered_state("Select a content match", None, cx)) - }) - .when( - view.preview_target.is_some() && view.preview_error.is_none(), - |this| { - this.child( - Input::new(&view.preview_input) - .appearance(false) - .bordered(false) - .focus_bordered(false) - .disabled(true) - .font_family(FONT_FAMILY) - .text_size(rems(0.875)) - .size_full(), - ) - }, - ) - .into_any_element() -} - -fn centered_state( - message: impl Into, - focus_handle: Option, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .text_color(theme.text_subtle) - .when_some(focus_handle, |this, focus_handle| { - this.track_focus(&focus_handle).on_mouse_down( - gpui::MouseButton::Left, - move |_, window, cx| { - window.focus(&focus_handle, cx); - }, - ) - }) - .child( - ComponentIcon::empty() - .path(super::icon_for_kind(registry::FILE_SEARCH.id).path()) - .text_color(theme.text_muted), - ) - .child(div().text_sm().child(message.into())) - .into_any_element() -} - -fn icon_for_result(result: &::file_search::FileSearchResult) -> IconName { - super::icon_for_path(&result.absolute_path).unwrap_or(IconName::File) -} - -fn icon_for_content_result(result: &::file_search::ContentSearchResult) -> IconName { - super::icon_for_path(&result.absolute_path).unwrap_or(IconName::File) -} - -fn highlighted_match_text(text: String, query: &str, color: gpui::Rgba) -> AnyElement { - let ranges = match_ranges(&text, query); - if ranges.is_empty() { - return StyledText::new(text).into_any_element(); - } - - let style = HighlightStyle { - color: Some(gpui::Hsla::from(color)), - font_weight: Some(FontWeight::SEMIBOLD), - ..Default::default() - }; - StyledText::new(text) - .with_highlights(ranges.into_iter().map(|range| (range, style))) - .into_any_element() -} - -fn match_ranges(text: &str, query: &str) -> Vec> { - let mut ranges = Vec::new(); - for token in query.split_whitespace().filter_map(clean_query_token) { - if let Some(range) = case_insensitive_substring(text, token) { - ranges.push(range); - } else { - ranges.extend(fuzzy_match_ranges(text, token)); - } - } - - merge_ranges(ranges) -} - -fn clean_query_token(token: &str) -> Option<&str> { - let token = token.trim_start_matches('!').trim_start_matches("./"); - if token.is_empty() || token.starts_with("git:") || token.starts_with("status:") { - None - } else { - Some(token) - } -} - -fn case_insensitive_substring(text: &str, pattern: &str) -> Option> { - let pattern_chars = pattern.chars().collect::>(); - if pattern_chars.is_empty() { - return None; - } - - let text_chars = char_spans(text); - if pattern_chars.len() > text_chars.len() { - return None; - } - - for start_ix in 0..=text_chars.len() - pattern_chars.len() { - if pattern_chars - .iter() - .enumerate() - .all(|(offset, ch)| chars_equal(text_chars[start_ix + offset].2, *ch)) - { - return Some(text_chars[start_ix].0..text_chars[start_ix + pattern_chars.len() - 1].1); - } - } - - None -} - -fn fuzzy_match_ranges(text: &str, pattern: &str) -> Vec> { - let text_chars = char_spans(text); - let mut ranges = Vec::new(); - let mut start_ix = 0; - - for query_ch in pattern.chars() { - let Some((matched_ix, (start, end, _))) = text_chars - .iter() - .enumerate() - .skip(start_ix) - .find(|(_, (_, _, text_ch))| chars_equal(*text_ch, query_ch)) - else { - return Vec::new(); - }; - ranges.push(*start..*end); - start_ix = matched_ix + 1; - } - - ranges -} - -fn char_spans(text: &str) -> Vec<(usize, usize, char)> { - let chars = text.char_indices().collect::>(); - chars - .iter() - .enumerate() - .map(|(ix, (start, ch))| { - let end = chars - .get(ix + 1) - .map(|(next, _)| *next) - .unwrap_or(text.len()); - (*start, end, *ch) - }) - .collect() -} - -fn chars_equal(left: char, right: char) -> bool { - left == right || left.eq_ignore_ascii_case(&right) -} - -fn byte_column_to_char_column(line: &str, byte_column: usize) -> usize { - let byte_column = byte_column.min(line.len()); - let byte_column = if line.is_char_boundary(byte_column) { - byte_column - } else { - line.char_indices() - .map(|(ix, _)| ix) - .take_while(|ix| *ix < byte_column) - .last() - .unwrap_or(0) - }; - line[..byte_column].chars().count() -} - -fn component_editor_language(language: &str) -> &str { - match language { - "shellscript" => "bash", - "typescriptreact" => "tsx", - "javascriptreact" => "javascript", - other => other, - } -} - -fn merge_ranges(mut ranges: Vec>) -> Vec> { - ranges.sort_by_key(|range| range.start); - let mut merged: Vec> = Vec::new(); - for range in ranges { - if let Some(last) = merged.last_mut() - && range.start <= last.end - { - last.end = last.end.max(range.end); - continue; - } - merged.push(range); - } - merged -} - -fn format_size(size: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - const GB: f64 = MB * 1024.0; - let size = size as f64; - - if size >= GB { - format!("{:.1} GB", size / GB) - } else if size >= MB { - format!("{:.1} MB", size / MB) - } else if size >= KB { - format!("{:.1} KB", size / KB) - } else { - format!("{} B", size as u64) - } -} - -fn spawn_search( - request: SearchRequest, - delay: Duration, - cx: &mut Context, -) { - cx.spawn(async move |this, cx| { - if !delay.is_zero() { - cx.background_executor().timer(delay).await; - } - - let generation = request.generation; - let kind = request.kind; - let should_run = this - .update(cx, |_, cx| { - cx.update_global::(|state, _| state.begin_search(generation, kind)) - }) - .unwrap_or(false); - if !should_run { - return; - } - - let index = request.index.clone(); - let query = request.query.clone(); - let mode = request.mode; - let limit = request.limit; - let result = cx - .background_executor() - .spawn(async move { - match mode { - SearchMode::Name => index - .search(&query, limit) - .map(SearchResult::Name) - .map_err(|error| error.to_string()), - SearchMode::Content => index - .search_content(&query, limit) - .map(SearchResult::Content) - .map_err(|error| error.to_string()), - } - }) - .await; - - let _ = this.update(cx, |_, cx| { - let next = cx.update_global::(|state, _| { - state.finish_search(generation, result, kind) - }); - cx.notify(); - if let Some(next) = next { - spawn_search(next, Duration::ZERO, cx); - } - }); - }) - .detach(); -} - -fn schedule_scan_refresh(cx: &mut Context) { - let request = cx.update_global::(|state, _| { - if state.refresh_scheduled { - return None; - } - - let index = state.active_index.clone()?; - state.refresh_scheduled = true; - Some(SearchRequest { - index, - mode: state.mode, - query: state.query.clone(), - generation: state.generation, - limit: state.result_limit, - kind: SearchKind::Refresh, - }) - }); - - if let Some(request) = request { - spawn_search(request, SCAN_REFRESH_INTERVAL, cx); - } -} - -fn sync_preview_soft_wrap( - window: &mut Window, - cx: &mut Context, -) { - let soft_wrap = preview_soft_wrap_enabled(cx); - let input = - cx.update_global::(|state, _| state.sync_preview_soft_wrap(soft_wrap)); - if let Some(input) = input { - input.update(cx, |input, cx| { - input.set_soft_wrap(soft_wrap, window, cx); - }); - } -} - -fn preview_soft_wrap_enabled(cx: &mut Context) -> bool { - cx.settings() - .get(file_editor::SOFT_WRAP_SETTING_ID) - .and_then(SettingValue::as_bool) - .unwrap_or(false) -} - -fn schedule_load_more(visible_end: usize, cx: &mut Context) { - let request = - cx.update_global::(|state, _| state.prepare_load_more(visible_end)); - if let Some(request) = request { - spawn_search(request, Duration::ZERO, cx); - } -} - -fn schedule_preview_prefetch(cx: &mut Context) { - let request = cx.update_global::(|state, _| state.prepare_preview_prefetch()); - if let Some(request) = request { - spawn_preview_prefetch(request, cx); - } -} - -fn schedule_preview_load(cx: &mut Context) { - let request = cx.update_global::(|state, _| state.prepare_preview()); - if let Some(request) = request { - spawn_preview_load(request, cx); - } -} - -fn spawn_preview_load( - request: PreviewRequest, - cx: &mut Context, -) { - cx.spawn(async move |this, cx| { - let generation = request.generation; - let target = request.target.clone(); - let result = cx - .background_executor() - .spawn(async move { read_preview_file(&target.path) }) - .await; - - let _ = this.update(cx, |_, cx| { - let updated = cx.update_global::(|state, _| { - state.finish_preview(generation, result) - }); - if updated { - cx.notify(); - } - }); - }) - .detach(); -} - -fn read_preview_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|error| format!("Unable to read preview: {error}"))? - .replace("\r\n", "\n"); - let language = language::from_path(path) - .map(|language| component_editor_language(language.as_str()).to_string()) - .unwrap_or_else(|| "plaintext".to_string()); - let line_starts = line_starts(&content); - Ok(PreviewFile { - content: Arc::::from(content), - language, - line_starts: Arc::from(line_starts), - }) -} - -fn line_starts(content: &str) -> Vec { - let mut starts = vec![0]; - starts.extend( - content - .bytes() - .enumerate() - .filter_map(|(ix, byte)| (byte == b'\n').then_some(ix + 1)), - ); - starts -} - -fn preview_slice( - file: &PreviewFile, - start_line: usize, - end_line: usize, - trailing_padding: usize, -) -> Arc { - let line_count = file.line_starts.len().max(1); - let start_line = start_line.min(line_count.saturating_sub(1)); - let end_line = end_line.max(start_line + 1).min(line_count); - let start_byte = file.line_starts[start_line].min(file.content.len()); - let end_byte = if end_line < line_count { - file.line_starts[end_line] - .saturating_sub(1) - .min(file.content.len()) - } else { - file.content.len() - }; - - let mut content = String::from(&file.content[start_byte..end_byte]); - if trailing_padding > 0 { - content.push_str(&"\n".repeat(trailing_padding)); - } - Arc::::from(content) -} - -fn spawn_preview_prefetch( - request: PreviewPrefetchRequest, - cx: &mut Context, -) { - cx.spawn(async move |this, cx| { - let generation = request.generation; - let files = request.files; - let loaded = cx - .background_executor() - .spawn(async move { - files - .into_iter() - .filter_map(|file| { - let preview_file = read_preview_file(&file.path).ok()?; - Some(PreviewFileData { - path: file.path, - file: preview_file, - }) - }) - .collect::>() - }) - .await; - - let _ = this.update(cx, |_, cx| { - let updated = cx.update_global::(|state, _| { - state.finish_preview_prefetch(generation, loaded) - }); - if updated { - cx.notify(); - } - }); - }) - .detach(); -} - -fn sync_preview_input( - window: &mut Window, - cx: &mut Context, -) { - let Some(update) = cx.update_global::(|state, _| state.take_preview_update()) - else { - return; - }; - - let target_line = update.cursor_line; - - update.input.update(cx, |input, cx| { - input.set_line_number_start(update.line_number_base, cx); - if let Some(content) = update.content { - input.set_value(content.as_ref(), window, cx); - } - if update.language_changed { - input.set_highlighter(update.language, cx); - } - input.set_cursor_position_without_focus( - gpui_component::input::Position::new(target_line as u32, update.column as u32), - cx, - ); - if !input.center_cursor_in_view(cx) { - let line_height = input - .line_height() - .unwrap_or_else(|| rems(1.25).to_pixels(window.rem_size())); - let centered_top_line = target_line.saturating_sub(PREVIEW_FALLBACK_CENTER_OFFSET); - input.set_scroll_offset( - point(px(0.0), -(line_height * centered_top_line as f32)), - cx, - ); - } - }); -} diff --git a/crates/ui/src/tabs/file_tree/actions.rs b/crates/ui/src/tabs/file_tree/actions.rs deleted file mode 100644 index 99f91ff1..00000000 --- a/crates/ui/src/tabs/file_tree/actions.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::path::PathBuf; - -use gpui::{App, Entity, Window}; - -use file_tree::FileTree; - -use crate::tabs::file_tree::state::ActiveFileTreeUi; - -pub fn begin_rename( - path: PathBuf, - file_tree: &Entity, - window: &mut Window, - cx: &mut App, -) { - let original = path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .unwrap_or_default(); - - file_tree.update(cx, |tree, cx| { - tree.start_rename(path, cx); - }); - - if let Some(input) = cx.file_tree_ui().map(|ui| ui.input()) { - input.update(cx, |input, cx| { - input.set_value(original, window, cx); - input.focus(window, cx); - }); - } -} - -pub fn focus_new_entry_input(window: &mut Window, cx: &mut App) { - if let Some(input) = cx.file_tree_ui().map(|ui| ui.input()) { - input.update(cx, |input, cx| { - input.set_value("", window, cx); - input.focus(window, cx); - }); - } -} - -pub fn commit_pending_input(file_tree: &Entity, cx: &mut App) { - let Some(input) = cx.file_tree_ui().map(|ui| ui.input()) else { - return; - }; - let value = input.read(cx).value().to_string(); - file_tree.update(cx, |tree, cx| { - if tree.new_entry_draft().is_some() { - tree.apply_new_entry(value, cx); - } else if tree.rename_target().is_some() { - tree.apply_rename(value, cx); - } - }); -} diff --git a/crates/ui/src/tabs/file_tree/drag.rs b/crates/ui/src/tabs/file_tree/drag.rs deleted file mode 100644 index c018f92f..00000000 --- a/crates/ui/src/tabs/file_tree/drag.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::path::PathBuf; - -use file_tree::NodeKind; -use gpui::{ - Context, IntoElement, MouseButton, Pixels, Point, Render, SharedString, Window, div, - prelude::*, rems, -}; -use icons::{Icon, IconName}; -use theme::ActiveTheme; - -use super::FileTreeUi; - -#[derive(Clone)] -pub struct FileNodeDrag { - pub paths: Vec, - pub label: SharedString, - pub icon: IconName, - pub kind: NodeKind, - position: Point, -} - -impl FileNodeDrag { - pub fn new(paths: Vec, label: SharedString, icon: IconName, kind: NodeKind) -> Self { - Self { - paths, - label, - icon, - kind, - position: Point::default(), - } - } - - pub fn position(mut self, position: Point) -> Self { - self.position = position; - self - } - - pub fn count(&self) -> usize { - self.paths.len() - } -} - -impl Render for FileNodeDrag { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = *cx.theme(); - let rem = window.rem_size(); - let count = self.count(); - let is_multi = count > 1; - let display_label: SharedString = if is_multi { - format!("{count} items").into() - } else { - self.label.clone() - }; - - div() - .pl(self.position.x - rems(0.5).to_pixels(rem)) - .pt(self.position.y - rems(0.75).to_pixels(rem)) - .on_mouse_up(MouseButton::Left, |_, window, cx| { - let Some(pending_drop) = - cx.update_global::(|ui, _| ui.take_pending_drop()) - else { - return; - }; - if !pending_drop.bounds.contains(&window.mouse_position()) { - return; - } - cx.stop_propagation(); - pending_drop.tree.update(cx, |tree, cx| { - tree.move_into(pending_drop.paths, pending_drop.destination, cx); - }); - }) - .child( - div() - .h(rems(1.625)) - .px_2() - .flex() - .items_center() - .gap_2() - .rounded(rems(0.3125)) - .bg(theme.bg_selected) - .text_sm() - .text_color(theme.text_emphasis) - .shadow_lg() - .child( - Icon::new(self.icon) - .size_rem(0.875) - .color(theme.text) - .into_any_element(), - ) - .child(display_label), - ) - } -} diff --git a/crates/ui/src/tabs/file_tree/menu.rs b/crates/ui/src/tabs/file_tree/menu.rs deleted file mode 100644 index 2bf056c6..00000000 --- a/crates/ui/src/tabs/file_tree/menu.rs +++ /dev/null @@ -1,242 +0,0 @@ -use std::{path::PathBuf, rc::Rc}; - -use gpui::{App, ClickEvent, Context, Entity, Window, div, prelude::*, rems}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, - menu::{PopupMenu, PopupMenuItem}, -}; - -use file_tree::{ClipboardOp, FileTree, NodeKind}; -use icons::IconName; -use theme::ActiveTheme; - -use crate::tabs::file_tree::actions; - -const MENU_WIDTH_REM: f32 = 13.0; - -type FileTreeMenuHandler = Rc; - -pub fn build( - entity: Entity, - target: PathBuf, - menu: PopupMenu, - window: &mut Window, - cx: &mut Context, -) -> PopupMenu { - select_context_target(&entity, &target, cx); - - // Operations apply to the full multi-selection if the right-clicked target - // belongs to it; otherwise they apply only to the target. - let (op_paths, has_clipboard, cut_active) = { - let tree = entity.read(cx); - let op_paths = if tree.is_selected(&target) && tree.selected_count() > 1 { - tree.selected_paths().iter().cloned().collect() - } else { - vec![target.clone()] - }; - let clipboard = tree.clipboard().map(|(op, _)| op); - ( - op_paths, - clipboard.is_some(), - matches!(clipboard, Some(ClipboardOp::Cut)), - ) - }; - let multi = op_paths.len() > 1; - let menu_width = rems(MENU_WIDTH_REM).to_pixels(window.rem_size()); - - menu.min_w(menu_width) - .item(menu_item( - IconName::FileAdd, - "New File", - true, - false, - new_entry_handler(entity.clone(), target.clone(), NodeKind::File), - )) - .item(menu_item( - IconName::FolderAdd, - "New Folder", - true, - false, - new_entry_handler(entity.clone(), target.clone(), NodeKind::Directory), - )) - .separator() - .item(menu_item( - IconName::Edit, - if multi { "Cut Selection" } else { "Cut" }, - !op_paths.is_empty(), - false, - cut_handler(entity.clone(), op_paths.clone()), - )) - .item(menu_item( - IconName::Copy, - if multi { "Copy Selection" } else { "Copy" }, - !op_paths.is_empty(), - false, - copy_handler(entity.clone(), op_paths.clone()), - )) - .item(menu_item( - IconName::Clippy, - if cut_active { "Paste (move)" } else { "Paste" }, - has_clipboard, - false, - paste_handler(entity.clone(), target.clone()), - )) - .separator() - .item(menu_item( - IconName::Edit, - "Rename", - !multi, - false, - rename_handler(entity.clone(), target.clone()), - )) - .item(menu_item( - IconName::Folder, - "Reveal in File Explorer", - true, - false, - reveal_handler(target.clone()), - )) - .separator() - .item(menu_item( - IconName::Trash, - "Move to Trash", - !op_paths.is_empty(), - true, - trash_handler(entity.clone(), op_paths.clone()), - )) - .item(menu_item( - IconName::Close, - "Delete Permanently", - !op_paths.is_empty(), - true, - delete_handler(entity, op_paths), - )) -} - -fn select_context_target( - entity: &Entity, - target: &PathBuf, - cx: &mut Context, -) { - entity.update(cx, |tree, cx| { - if !tree.is_selected(target) { - tree.select(target.clone(), cx); - } - }); -} - -fn new_entry_handler( - entity: Entity, - target: PathBuf, - kind: NodeKind, -) -> FileTreeMenuHandler { - Rc::new(move |_, window, cx| { - cx.stop_propagation(); - entity.update(cx, |tree, cx| { - tree.start_new_entry(Some(&target), kind, cx); - }); - actions::focus_new_entry_input(window, cx); - }) -} - -fn cut_handler(entity: Entity, paths: Vec) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - if !paths.is_empty() { - entity.update(cx, |tree, cx| tree.cut(paths.clone(), cx)); - } - }) -} - -fn copy_handler(entity: Entity, paths: Vec) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - if !paths.is_empty() { - entity.update(cx, |tree, cx| tree.copy(paths.clone(), cx)); - } - }) -} - -fn paste_handler(entity: Entity, target: PathBuf) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - entity.update(cx, |tree, cx| tree.paste_into(target.clone(), cx)); - }) -} - -fn rename_handler(entity: Entity, target: PathBuf) -> FileTreeMenuHandler { - Rc::new(move |_, window, cx| { - cx.stop_propagation(); - actions::begin_rename(target.clone(), &entity, window, cx); - }) -} - -fn reveal_handler(target: PathBuf) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - cx.reveal_path(&target); - }) -} - -fn trash_handler(entity: Entity, paths: Vec) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - if !paths.is_empty() { - entity.update(cx, |tree, cx| tree.trash(paths.clone(), cx)); - } - }) -} - -fn delete_handler(entity: Entity, paths: Vec) -> FileTreeMenuHandler { - Rc::new(move |_, _, cx| { - cx.stop_propagation(); - if !paths.is_empty() { - entity.update(cx, |tree, cx| tree.delete(paths.clone(), cx)); - } - }) -} - -fn menu_item( - icon: IconName, - label: &'static str, - enabled: bool, - danger: bool, - listener: FileTreeMenuHandler, -) -> PopupMenuItem { - PopupMenuItem::element(move |_, cx| { - let theme = *cx.theme(); - let text_color = if !enabled { - theme.text_subtle - } else if danger { - theme.danger - } else { - theme.text - }; - let icon_color = if !enabled { - theme.text_subtle - } else if danger { - theme.danger - } else { - theme.text_muted - }; - - div() - .w_full() - .flex() - .items_center() - .gap_2() - .text_color(text_color) - .child( - div() - .w(rems(1.0)) - .flex() - .items_center() - .justify_center() - .text_color(icon_color) - .child(ComponentIcon::empty().path(icon.path()).small()), - ) - .child(label) - }) - .disabled(!enabled) - .on_click(move |event, window, cx| listener(event, window, cx)) -} diff --git a/crates/ui/src/tabs/file_tree/mod.rs b/crates/ui/src/tabs/file_tree/mod.rs deleted file mode 100644 index ab365b2f..00000000 --- a/crates/ui/src/tabs/file_tree/mod.rs +++ /dev/null @@ -1,265 +0,0 @@ -mod actions; -pub mod drag; -mod menu; -mod row; -mod state; - -pub use actions::begin_rename; -pub use state::{ActiveFileTreeUi, FileTreeUi}; - -use std::path::Path; - -use gpui::{AnyElement, Context, Entity, IntoElement, SharedString, Window, div, prelude::*}; - -use file_tree::{ActiveFileTree, FileTree, NewEntryDraft, NodeKind}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, Size, - alert::Alert, - tree::{TreeEntry, TreeItem, tree as component_tree}, -}; -use icons::IconName; -use tabs::registry; -use theme::ActiveTheme; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; - -pub fn render( - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - render_content(window, cx) -} - -fn render_content( - _window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let entity = match cx.file_tree().cloned() { - Some(e) => e, - None => return empty_state(cx), - }; - - let (root, error, new_entry) = { - let tree = entity.read(cx); - let Some(root) = tree.root().map(Path::to_path_buf) else { - return empty_state(cx); - }; - ( - root.clone(), - tree.error().cloned(), - tree.new_entry_draft().cloned(), - ) - }; - - let root_label: SharedString = root - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| root.display().to_string()) - .into(); - - let Some(tree_state) = cx.file_tree_ui().map(|ui| ui.tree()) else { - return empty_state(cx); - }; - let root_item = build_tree_item( - &entity, - &root, - root_label, - NodeKind::Directory, - &new_entry, - cx, - ); - tree_state.update(cx, |state, cx| state.set_items(vec![root_item], cx)); - let delegate = cx.entity().clone(); - let entity_for_rows = entity.clone(); - let new_entry_for_rows = new_entry.clone(); - - div() - .relative() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .child(error_banner(error.clone(), &entity, cx)) - .child( - div().relative().flex_1().min_h_0().child( - div().id("file-tree-scroll").size_full().child( - component_tree(&tree_state, move |ix, entry, _, window, cx| { - row::render_tree_entry::( - ix, - entry, - &entity_for_rows, - &delegate, - &new_entry_for_rows, - window, - cx, - ) - }) - .context_menu({ - let entity = entity.clone(); - move |_, entry, popup_menu, window, cx| { - let Some(path) = entry_path(entry) else { - return popup_menu; - }; - menu::build(entity.clone(), path, popup_menu, window, cx) - } - }) - .size_full(), - ), - ), - ) - .into_any_element() -} - -fn entry_path(entry: &TreeEntry) -> Option { - let id = entry.item().id.as_str(); - id.strip_prefix(FILE_TREE_DIR_PREFIX) - .or_else(|| id.strip_prefix(FILE_TREE_FILE_PREFIX)) - .map(std::path::PathBuf::from) -} - -pub(super) const FILE_TREE_DIR_PREFIX: &str = "dir:"; -pub(super) const FILE_TREE_FILE_PREFIX: &str = "file:"; -pub(super) const FILE_TREE_NEW_PREFIX: &str = "new:"; - -fn build_tree_item( - entity: &Entity, - path: &Path, - name: SharedString, - kind: NodeKind, - new_entry: &Option, - cx: &mut Context, -) -> TreeItem { - let state = compute_row_state(entity, path, cx); - let id = match kind { - NodeKind::Directory => format!("{FILE_TREE_DIR_PREFIX}{}", path.to_string_lossy()), - NodeKind::File => format!("{FILE_TREE_FILE_PREFIX}{}", path.to_string_lossy()), - }; - let mut item = TreeItem::new(id, name).expanded(state.is_expanded); - if state.is_renaming { - item = item.disabled(true); - } - if kind == NodeKind::File { - return item; - } - - let snapshot = { - let tree = entity.read(cx); - tree.children_of(path) - .map(|c| c.to_vec()) - .unwrap_or_default() - }; - let new_here = new_entry - .as_ref() - .filter(|draft| draft.parent.as_path() == path); - let mut children = Vec::new(); - if let Some(draft) = new_here.filter(|draft| draft.kind == NodeKind::Directory) { - children.push(new_entry_tree_item(draft)); - } - children.extend( - snapshot - .iter() - .filter(|n| matches!(n.kind, file_tree::NodeKind::Directory)) - .map(|node| { - build_tree_item( - entity, - &node.path, - node.name.clone(), - node.kind, - new_entry, - cx, - ) - }), - ); - if let Some(draft) = new_here.filter(|draft| draft.kind == NodeKind::File) { - children.push(new_entry_tree_item(draft)); - } - children.extend( - snapshot - .iter() - .filter(|n| matches!(n.kind, file_tree::NodeKind::File)) - .map(|node| { - build_tree_item( - entity, - &node.path, - node.name.clone(), - node.kind, - new_entry, - cx, - ) - }), - ); - - item.children(children) -} - -fn new_entry_tree_item(draft: &NewEntryDraft) -> TreeItem { - TreeItem::new( - format!( - "{FILE_TREE_NEW_PREFIX}{}:{}", - match draft.kind { - NodeKind::Directory => "dir", - NodeKind::File => "file", - }, - draft.parent.to_string_lossy() - ), - "", - ) - .disabled(true) -} -fn compute_row_state( - entity: &Entity, - path: &Path, - cx: &Context, -) -> row::RowState { - let tree = entity.read(cx); - row::RowState { - is_expanded: tree.is_expanded(path), - is_selected: tree.is_selected(path), - is_renaming: tree - .rename_target() - .is_some_and(|r| r.path.as_path() == path), - } -} - -fn error_banner( - error: Option, - entity: &Entity, - _cx: &mut Context, -) -> AnyElement { - let Some(message) = error else { - return div().into_any_element(); - }; - let entity = entity.clone(); - Alert::error("file-tree-error", message) - .banner() - .with_size(Size::Small) - .icon(ComponentIcon::empty().path(IconName::Close.path())) - .on_close(move |_, _, cx| { - entity.update(cx, |tree, _| tree.clear_error()); - cx.refresh_windows(); - }) - .into_any_element() -} - -fn empty_state(cx: &mut Context) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .text_color(theme.text_subtle) - .child( - ComponentIcon::empty() - .path(super::icon_for_kind(registry::FILE_TREE.id).path()) - .text_color(theme.text_muted), - ) - .child(div().text_sm().child("No workspace open")) - .into_any_element() -} diff --git a/crates/ui/src/tabs/file_tree/row.rs b/crates/ui/src/tabs/file_tree/row.rs deleted file mode 100644 index 47e753f4..00000000 --- a/crates/ui/src/tabs/file_tree/row.rs +++ /dev/null @@ -1,3 +0,0 @@ -include!("row/directory_rows.rs"); -include!("row/file_and_entry_rows.rs"); -include!("row/drag_and_helpers.rs"); diff --git a/crates/ui/src/tabs/file_tree/row/directory_rows.rs b/crates/ui/src/tabs/file_tree/row/directory_rows.rs deleted file mode 100644 index a92ae161..00000000 --- a/crates/ui/src/tabs/file_tree/row/directory_rows.rs +++ /dev/null @@ -1,478 +0,0 @@ -use std::path::{Path, PathBuf}; - -use gpui::{ - AnyElement, App, Entity, IntoElement, KeyDownEvent, MouseButton, Rgba, SharedString, Window, - div, prelude::*, rems, -}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, - button::{Button, ButtonVariants}, - list::ListItem, - tree::TreeEntry, -}; - -use file_tree::{FileTree, NewEntryDraft, NodeKind}; -use icons::{Icon, IconName}; -use theme::{ActiveTheme, Theme}; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; -use crate::tabs::file_tree::drag::FileNodeDrag; -use crate::tabs::file_tree::state::{ActiveFileTreeUi, FileTreeUi, PendingFileTreeDrop}; - -pub const ROW_HEIGHT: gpui::Rems = gpui::Rems(1.625); -pub const INDENT_REM: f32 = 1.25; -const GUIDE_OFFSET_REM: f32 = 0.625; -const GUIDE_WIDTH_REM: f32 = 0.0625; -const ICON_WIDTH_REM: f32 = 1.25; - -#[derive(Clone, Copy, Default)] -pub struct RowState { - pub is_expanded: bool, - pub is_selected: bool, - pub is_renaming: bool, -} - -pub fn render_tree_entry( - ix: usize, - entry: &TreeEntry, - entity: &Entity, - delegate: &Entity, - new_entry: &Option, - _window: &mut Window, - cx: &mut App, -) -> ListItem { - let item = entry.item(); - let id = item.id.as_str(); - if id.starts_with(super::FILE_TREE_NEW_PREFIX) { - if let Some(draft) = new_entry.as_ref() { - return render_new_entry_item(ix, draft, entity, entry.depth(), cx); - } - return ListItem::new(ix).disabled(true); - } - - let (path, kind) = if let Some(path) = id.strip_prefix(super::FILE_TREE_DIR_PREFIX) { - (PathBuf::from(path), NodeKind::Directory) - } else if let Some(path) = id.strip_prefix(super::FILE_TREE_FILE_PREFIX) { - (PathBuf::from(path), NodeKind::File) - } else { - (PathBuf::from(id), NodeKind::File) - }; - let state = compute_row_state_app(entity, &path, cx); - let icon_name = match kind { - NodeKind::Directory if state.is_expanded => IconName::FolderOpened, - NodeKind::Directory => IconName::Folder, - NodeKind::File => icon_for_file(&path), - }; - if state.is_renaming { - return ListItem::new(ix) - .h(ROW_HEIGHT) - .px(rems(0.375)) - .py_0() - .disabled(true) - .child(rename_input_body_app(entity, entry.depth(), icon_name, cx)); - } - - let theme = *cx.theme(); - let path_for_click = path.clone(); - let path_for_drag = path.clone(); - let name_for_drag = item.label.clone(); - let entity_for_click = entity.clone(); - let delegate_for_click = delegate.clone(); - let is_root = kind == NodeKind::Directory && entry.depth() == 0; - let entity_for_suffix = entity.clone(); - - let body = draggable_node_body( - entity, - &path_for_drag, - entry.depth(), - icon_name, - name_for_drag.clone(), - state.is_selected, - kind, - theme, - cx, - ); - - ListItem::new(ix) - .h(ROW_HEIGHT) - .px(rems(0.375)) - .py_0() - .when(state.is_selected, |item| item.bg(theme.bg_hover)) - .text_color(if state.is_selected { - theme.text_emphasis - } else if kind == NodeKind::Directory && entry.depth() == 0 { - theme.text_header - } else { - theme.text - }) - .child(body) - .on_click(move |event, _window, cx| { - let target = path_for_click.clone(); - let shift = event.modifiers().shift; - entity_for_click.update(cx, |tree, cx| { - if shift { - tree.extend_selection_to(target.clone(), cx); - } else { - tree.select(target.clone(), cx); - if kind == NodeKind::Directory { - tree.toggle_expand(&target, cx); - } - } - }); - if !shift && kind == NodeKind::File { - delegate_for_click.update(cx, |delegate, cx| delegate.open_file(target, cx)); - } - }) - .suffix(move |window, cx| { - if is_root { - root_actions_app::(&entity_for_suffix, window, cx) - } else { - div().into_any_element() - } - }) -} - -fn root_actions_app( - entity: &Entity, - _window: &mut Window, - _cx: &mut App, -) -> AnyElement { - let new_file_entity = entity.clone(); - let new_dir_entity = entity.clone(); - let refresh_entity = entity.clone(); - let collapse_entity = entity.clone(); - div() - .flex() - .flex_none() - .items_center() - .gap_1() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(root_action_button( - "ft-action-new-file", - IconName::FileAdd, - move |window, cx| { - let anchor = new_file_entity - .read(cx) - .selected() - .map(|path| path.to_path_buf()); - new_file_entity.update(cx, |tree, cx| { - tree.start_new_entry(anchor.as_deref(), NodeKind::File, cx); - }); - super::actions::focus_new_entry_input(window, cx); - }, - )) - .child(root_action_button( - "ft-action-new-folder", - IconName::FolderAdd, - move |window, cx| { - let anchor = new_dir_entity - .read(cx) - .selected() - .map(|path| path.to_path_buf()); - new_dir_entity.update(cx, |tree, cx| { - tree.start_new_entry(anchor.as_deref(), NodeKind::Directory, cx); - }); - super::actions::focus_new_entry_input(window, cx); - }, - )) - .child(root_action_button( - "ft-action-refresh", - IconName::Refresh, - move |_, cx| { - refresh_entity.update(cx, |tree, cx| { - if let Some(root) = tree.root().map(Path::to_path_buf) { - tree.reload_dir(&root); - } - cx.notify(); - }); - }, - )) - .child(root_action_button( - "ft-action-collapse-all", - IconName::CollapseAll, - move |_, cx| { - collapse_entity.update(cx, |tree, cx| tree.collapse_all(cx)); - }, - )) - .into_any_element() -} - -fn root_action_button( - id: &'static str, - icon: IconName, - on_click: impl Fn(&mut Window, &mut App) + 'static, -) -> AnyElement { - Button::new(id) - .ghost() - .tab_stop(false) - .size(rems(1.375)) - .icon(ComponentIcon::empty().path(icon.path()).small()) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_click(window, cx); - }) - .into_any_element() -} - -fn compute_row_state_app(entity: &Entity, path: &Path, cx: &App) -> RowState { - let tree = entity.read(cx); - RowState { - is_expanded: tree.is_expanded(path), - is_selected: tree.is_selected(path), - is_renaming: tree - .rename_target() - .is_some_and(|rename| rename.path.as_path() == path), - } -} - -fn draggable_node_body( - entity: &Entity, - path: &Path, - depth: usize, - icon_name: IconName, - name: SharedString, - is_selected: bool, - kind: NodeKind, - theme: Theme, - cx: &mut App, -) -> AnyElement { - let icon_color = if is_selected { - theme.text - } else { - theme.text_muted - }; - let drag_paths = drag_paths_for_app(entity, path, cx); - let drop_entity = entity.clone(); - let pending_drop_entity = entity.clone(); - let drop_destination = match kind { - NodeKind::Directory => Some(path.to_path_buf()), - NodeKind::File => path.parent().map(Path::to_path_buf), - }; - let pending_drop_destination = drop_destination.clone(); - let drag_overlay_id = path_id("file-tree-node-drag", path); - div() - .id(path_id("file-tree-node-body", path)) - .relative() - .w_full() - .h(ROW_HEIGHT) - .flex() - .items_center() - .child(node_label( - depth, - icon_name, - name.clone(), - is_selected, - theme, - )) - .child( - div() - .id(drag_overlay_id) - .absolute() - .top_0() - .bottom_0() - .left_0() - .right_0() - .drag_over::(move |style, _, _, _| { - style.bg(gpui::Hsla::from(theme.accent).opacity(0.18)) - }) - .on_drag_move(move |event: &gpui::DragMoveEvent, _, cx| { - if !event.bounds.contains(&event.event.position) { - return; - } - let Some(destination) = pending_drop_destination.clone() else { - cx.update_global::(|ui, _| ui.clear_pending_drop()); - return; - }; - let drag = event.drag(cx); - if !can_drop_into_dir_app(drag, &destination) { - cx.update_global::(|ui, _| ui.clear_pending_drop()); - return; - } - let paths = drag.paths.clone(); - cx.update_global::(|ui, _| { - ui.set_pending_drop(PendingFileTreeDrop { - tree: pending_drop_entity.clone(), - paths, - destination, - bounds: event.bounds, - }); - }); - }) - .on_drop(move |drag: &FileNodeDrag, _, cx| { - cx.stop_propagation(); - cx.update_global::(|ui, _| ui.clear_pending_drop()); - let Some(destination) = drop_destination.clone() else { - return; - }; - if !can_drop_into_dir_app(drag, &destination) { - return; - } - let paths = drag.paths.clone(); - drop_entity.update(cx, |tree, cx| tree.move_into(paths, destination, cx)); - }) - .on_drag( - FileNodeDrag::new(drag_paths, name, icon_name, kind), - |drag, position, _, cx| cx.new(|_| drag.clone().position(position)), - ), - ) - .child(div().hidden().child(file_tree_icon(icon_name, icon_color))) - .into_any_element() -} - -fn drag_paths_for_app(entity: &Entity, row_path: &Path, cx: &App) -> Vec { - let tree = entity.read(cx); - if tree.is_selected(row_path) && tree.selected_count() > 1 { - tree.selected_paths().iter().cloned().collect() - } else { - vec![row_path.to_path_buf()] - } -} - -fn can_drop_into_dir_app(drag: &FileNodeDrag, dest_dir: &Path) -> bool { - if drag.paths.is_empty() { - return false; - } - if drag.paths.iter().any(|path| dest_dir.starts_with(path)) { - return false; - } - drag.paths - .iter() - .any(|path| path.parent() != Some(dest_dir)) -} - -fn render_new_entry_item( - ix: usize, - draft: &NewEntryDraft, - entity: &Entity, - depth: usize, - cx: &mut App, -) -> ListItem { - let icon_name = match draft.kind { - NodeKind::Directory => IconName::Folder, - NodeKind::File => IconName::File, - }; - ListItem::new(ix) - .h(ROW_HEIGHT) - .px(rems(0.375)) - .py_0() - .disabled(true) - .child(new_entry_input_body_app(entity, depth, icon_name, cx)) -} - -fn new_entry_input_body_app( - entity: &Entity, - depth: usize, - icon_name: IconName, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - let Some(input) = cx.file_tree_ui().map(|ui| ui.input()) else { - return div().into_any_element(); - }; - let entity_submit = entity.clone(); - let entity_cancel = entity.clone(); - let entity_out = entity.clone(); - let input_for_submit = input.clone(); - - div() - .flex() - .items_center() - .h(ROW_HEIGHT) - .w_full() - .on_mouse_down_out(move |_, _, cx| { - super::actions::commit_pending_input(&entity_out, cx); - }) - .child(indent_guides(depth, theme)) - .child( - div() - .w(rems(ICON_WIDTH_REM)) - .h(ROW_HEIGHT) - .flex_none() - .flex() - .items_center() - .justify_center() - .child(file_tree_icon(icon_name, theme.text_muted)), - ) - .child( - div() - .flex_1() - .min_w_0() - .capture_key_down(move |event: &KeyDownEvent, _: &mut Window, cx: &mut App| { - match event.keystroke.key.as_str() { - "enter" => { - cx.stop_propagation(); - let value = input_for_submit.read(cx).value().to_string(); - entity_submit.update(cx, |tree, cx| tree.apply_new_entry(value, cx)); - } - "escape" => { - cx.stop_propagation(); - entity_cancel.update(cx, |tree, cx| tree.cancel_new_entry(cx)); - } - _ => {} - } - }) - .child(Input::new(&input)), - ) - .into_any_element() -} - -fn rename_input_body_app( - entity: &Entity, - depth: usize, - icon_name: IconName, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - let Some(input) = cx.file_tree_ui().map(|ui| ui.input()) else { - return div().into_any_element(); - }; - let entity_submit = entity.clone(); - let entity_cancel = entity.clone(); - let entity_out = entity.clone(); - let input_for_submit = input.clone(); - - div() - .flex() - .items_center() - .h(ROW_HEIGHT) - .w_full() - .on_mouse_down_out(move |_, _, cx| { - super::actions::commit_pending_input(&entity_out, cx); - }) - .child(indent_guides(depth, theme)) - .child( - div() - .w(rems(ICON_WIDTH_REM)) - .h(ROW_HEIGHT) - .flex_none() - .flex() - .items_center() - .justify_center() - .child(file_tree_icon(icon_name, theme.text_muted)), - ) - .child( - div() - .flex_1() - .min_w_0() - .capture_key_down(move |event: &KeyDownEvent, _: &mut Window, cx: &mut App| { - match event.keystroke.key.as_str() { - "enter" => { - cx.stop_propagation(); - let value = input_for_submit.read(cx).value().to_string(); - entity_submit.update(cx, |tree, cx| tree.apply_rename(value, cx)); - } - "escape" => { - cx.stop_propagation(); - entity_cancel.update(cx, |tree, cx| tree.cancel_rename(cx)); - } - _ => {} - } - }) - .child(Input::new(&input)), - ) - .into_any_element() -} - -fn file_tree_icon(icon: IconName, color: Rgba) -> Icon { - Icon::new(icon).size_rem(0.875).color(color) -} diff --git a/crates/ui/src/tabs/file_tree/row/drag_and_helpers.rs b/crates/ui/src/tabs/file_tree/row/drag_and_helpers.rs deleted file mode 100644 index 7ec1c776..00000000 --- a/crates/ui/src/tabs/file_tree/row/drag_and_helpers.rs +++ /dev/null @@ -1,33 +0,0 @@ -pub fn indent_guides(depth: usize, theme: Theme) -> AnyElement { - if depth == 0 { - return div().flex_none().into_any_element(); - } - let mut row = div().flex().flex_none().h(ROW_HEIGHT); - for _ in 0..depth { - row = row.child( - div() - .relative() - .w(rems(INDENT_REM)) - .h(ROW_HEIGHT) - .flex_none() - .child( - div() - .absolute() - .left(rems(GUIDE_OFFSET_REM)) - .top_0() - .bottom_0() - .w(rems(GUIDE_WIDTH_REM)) - .bg(gpui::Hsla::from(theme.text).opacity(0.1)), - ), - ); - } - row.into_any_element() -} - -pub fn path_id(prefix: &'static str, path: &Path) -> gpui::ElementId { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - path.hash(&mut hasher); - let hash = hasher.finish() as usize; - gpui::ElementId::from((prefix, hash)) -} diff --git a/crates/ui/src/tabs/file_tree/row/file_and_entry_rows.rs b/crates/ui/src/tabs/file_tree/row/file_and_entry_rows.rs deleted file mode 100644 index 3bf2ceb5..00000000 --- a/crates/ui/src/tabs/file_tree/row/file_and_entry_rows.rs +++ /dev/null @@ -1,55 +0,0 @@ -use gpui_component::input::Input; - -fn node_label( - depth: usize, - icon: IconName, - name: SharedString, - is_selected: bool, - theme: Theme, -) -> AnyElement { - let icon_color = if is_selected { - theme.text - } else { - theme.text_muted - }; - - div() - .flex() - .items_center() - .h(ROW_HEIGHT) - .w_full() - .child(indent_guides(depth, theme)) - .child( - div() - .w(rems(ICON_WIDTH_REM)) - .h(ROW_HEIGHT) - .flex_none() - .flex() - .items_center() - .justify_center() - .child(file_tree_icon(icon, icon_color)), - ) - .child( - div() - .flex_1() - .min_w_0() - .pl(rems(0.25)) - .pr(rems(0.5)) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .into_any_element() -} - -fn icon_for_file(path: &Path) -> IconName { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && let Some(icon) = IconName::for_file_name(name) - { - return icon; - } - language::from_path(path) - .and_then(|id| IconName::for_language(id.as_str())) - .unwrap_or(IconName::File) -} diff --git a/crates/ui/src/tabs/file_tree/state.rs b/crates/ui/src/tabs/file_tree/state.rs deleted file mode 100644 index efa5aa3c..00000000 --- a/crates/ui/src/tabs/file_tree/state.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::path::PathBuf; - -use file_tree::{ActiveFileTree, FileTree}; -use gpui::{App, AppContext, Bounds, Entity, Global, Pixels}; -use gpui_component::{ - input::{InputEvent, InputState}, - tree::TreeState, -}; - -use super::actions; - -pub(crate) struct PendingFileTreeDrop { - pub tree: Entity, - pub paths: Vec, - pub destination: PathBuf, - pub bounds: Bounds, -} - -/// Holds stateful file tree UI components so they survive re-renders triggered -/// by file system updates. -pub struct FileTreeUi { - input: Entity, - tree: Entity, - pending_drop: Option, -} - -impl FileTreeUi { - pub fn install(window: &mut gpui::Window, cx: &mut App) { - let input = cx.new(|cx| InputState::new(window, cx)); - let tree = cx.new(|cx| TreeState::new(cx)); - cx.subscribe(&input, |_, event: &InputEvent, cx| { - if !matches!(event, InputEvent::Blur) { - return; - } - let Some(file_tree) = cx.file_tree().cloned() else { - return; - }; - actions::commit_pending_input(&file_tree, cx); - }) - .detach(); - cx.set_global(FileTreeUi { - input, - tree, - pending_drop: None, - }); - } - - pub fn input(&self) -> Entity { - self.input.clone() - } - - pub fn tree(&self) -> Entity { - self.tree.clone() - } - - pub(crate) fn set_pending_drop(&mut self, pending_drop: PendingFileTreeDrop) { - self.pending_drop = Some(pending_drop); - } - - pub(crate) fn clear_pending_drop(&mut self) { - self.pending_drop = None; - } - - pub(crate) fn take_pending_drop(&mut self) -> Option { - self.pending_drop.take() - } -} - -impl Global for FileTreeUi {} - -pub trait ActiveFileTreeUi { - fn file_tree_ui(&self) -> Option<&FileTreeUi>; -} - -impl ActiveFileTreeUi for App { - fn file_tree_ui(&self) -> Option<&FileTreeUi> { - self.try_global::() - } -} diff --git a/crates/ui/src/tabs/git/mod.rs b/crates/ui/src/tabs/git/mod.rs deleted file mode 100644 index bac416fa..00000000 --- a/crates/ui/src/tabs/git/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -use gpui::{AnyElement, Context, Global, Window}; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; - -include!("parts/state_and_header.rs"); -include!("parts/panels.rs"); -include!("parts/branches_modal.rs"); -include!("parts/branch_and_remote_modals.rs"); -include!("parts/tags_and_stashes.rs"); -include!("parts/modal_rows_and_buttons.rs"); -include!("parts/change_tree.rs"); -include!("parts/state_actions.rs"); -include!("parts/async_actions.rs"); - -pub fn render( - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - render_git(window, cx) -} diff --git a/crates/ui/src/tabs/git/parts/async_actions.rs b/crates/ui/src/tabs/git/parts/async_actions.rs deleted file mode 100644 index aea7829b..00000000 --- a/crates/ui/src/tabs/git/parts/async_actions.rs +++ /dev/null @@ -1,65 +0,0 @@ -fn apply_modal_list_result( - modal: GitModal, - result: Result, - cx: &mut Context, -) { - apply_modal_list_result_state(modal, result, cx); - cx.notify(); -} - -fn apply_modal_list_result_app( - modal: GitModal, - result: Result, - cx: &mut App, -) { - apply_modal_list_result_state(modal, result, cx); - cx.refresh_windows(); -} - -fn apply_modal_list_result_state( - modal: GitModal, - result: Result, - cx: &mut App, -) { - match result { - Ok(ModalList::Branches(branches)) if modal == GitModal::Branches => { - cx.update_global::(|state, _| { - state.branches = branches; - state.last_error = None; - }); - } - Ok(ModalList::Remotes(remotes)) if modal == GitModal::Remotes => { - cx.update_global::(|state, _| { - state.remotes = remotes; - state.last_error = None; - }); - } - Ok(ModalList::Stashes(stashes)) if modal == GitModal::Stashes => { - cx.update_global::(|state, _| { - state.stashes = stashes; - state.last_error = None; - }); - } - Ok(ModalList::Tags(tags)) if modal == GitModal::Tags => { - cx.update_global::(|state, _| { - state.tags = tags; - state.last_error = None; - }); - } - Ok(_) => {} - Err(error) => { - cx.update_global::(|state, _| state.last_error = None); - show_git_error(cx, modal_load_error_title(modal), git_error_message(error)); - } - } -} - -fn modal_load_error_title(modal: GitModal) -> &'static str { - match modal { - GitModal::Branches => "Failed to load branches", - GitModal::Remotes => "Failed to load remotes", - GitModal::Stashes => "Failed to load stashes", - GitModal::Tags => "Failed to load tags", - _ => "Git operation failed", - } -} diff --git a/crates/ui/src/tabs/git/parts/branch_and_remote_modals.rs b/crates/ui/src/tabs/git/parts/branch_and_remote_modals.rs deleted file mode 100644 index a5d23e0a..00000000 --- a/crates/ui/src/tabs/git/parts/branch_and_remote_modals.rs +++ /dev/null @@ -1,341 +0,0 @@ -fn branch_row(root: PathBuf, branch: Branch, cx: &mut App) -> AnyElement { - let theme = *cx.theme(); - let name = branch.name.clone(); - let is_current = branch.current; - let is_remote = branch.remote; - let row_id = SharedString::from(format!("git-branch:{name}")); - let delete_id = SharedString::from(format!("git-delete-branch:{name}")); - let switch_branch = name.clone(); - let delete_branch = name.clone(); - let root_switch = root.clone(); - let root_delete = root.clone(); - - div() - .id(row_id) - .flex() - .items_center() - .justify_between() - .gap_2() - .rounded(rems(0.375)) - .p_2p5() - .text_sm() - .text_color(if is_current { - theme.text_emphasis - } else { - theme.text - }) - .when(is_current, |this| { - this.bg(gpui::Hsla::from(theme.accent).opacity(if theme.is_dark { - 0.16 - } else { - 0.12 - })) - }) - .when(!is_current, |this| { - this.hover(move |this| this.bg(theme.bg_hover)) - .on_click(move |_, _, cx| { - let branch = switch_branch.clone(); - run_modal_action_app( - root_switch.clone(), - GitModal::Branches, - move |root| { - if is_remote { - kosmos_git::switch_remote_branch(root, &branch) - } else { - kosmos_git::switch_branch(root, &branch) - } - }, - cx, - ); - }) - }) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .items_center() - .gap_2() - .child( - div().flex_none().flex().items_center().child( - Icon::new(IconName::SourceControl) - .size(14.0) - .color(theme.text_muted), - ), - ) - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_1p5() - .child( - div() - .min_w_0() - .flex_1() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .when(is_remote, |this| { - this.child( - div().flex_none().child( - ComponentTag::secondary() - .with_size(Size::Small) - .border_0() - .child("Remote"), - ), - ) - }), - ), - ) - .when(!is_current && !is_remote, |this| { - this.child(div().flex_none().child(delete_button( - delete_id, - move |_, _, cx| { - let branch = delete_branch.clone(); - run_modal_action_app( - root_delete.clone(), - GitModal::Branches, - move |root| kosmos_git::delete_branch(root, &branch), - cx, - ); - }, - cx, - ))) - }) - .into_any_element() -} - -fn create_branch_modal_body(cx: &mut App) -> AnyElement { - let branch_name = cx - .global::() - .branch_name - .as_ref() - .unwrap() - .clone(); - - div() - .flex() - .flex_col() - .gap_3() - .child(input_row("Branch Name", branch_name)) - .into_any_element() -} - -fn create_branch_modal_footer(root: &Path, cx: &mut App) -> AnyElement { - let branch_name = cx - .global::() - .branch_name - .as_ref() - .unwrap() - .clone(); - let root_cancel = root.to_path_buf(); - let root_create = root.to_path_buf(); - let cancel_input = branch_name.clone(); - let create_input = branch_name.clone(); - - div() - .flex() - .justify_end() - .gap_2() - .child(action_button( - "git-cancel-create-branch", - "Cancel", - false, - move |_, window, cx| { - cancel_input.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root_cancel.clone(), GitModal::Branches, cx); - }, - cx, - )) - .child( - Button::new("git-confirm-create-branch") - .primary() - .label("Create") - .on_click(move |_, _, cx| { - let branch = create_input.read(cx).value().trim().to_string(); - if branch.is_empty() { - return; - } - run_modal_action_after_success_app( - root_create.clone(), - GitModal::Branches, - move |root| kosmos_git::create_branch(root, &branch), - move |cx| { - cx.update_global::(|state, _| { - state.modal = Some(GitModal::Branches) - }); - }, - cx, - ); - }), - ) - .into_any_element() -} - -fn remotes_modal_body(root: &Path, cx: &mut App) -> AnyElement { - let (remote_search, remotes) = { - let state = cx.global::(); - ( - state.remote_search.as_ref().unwrap().clone(), - state.remotes.clone(), - ) - }; - let theme = *cx.theme(); - let query = remote_search.read(cx).value().trim().to_lowercase(); - let has_remotes = !remotes.is_empty(); - let remotes = if query.is_empty() { - remotes - } else { - remotes - .into_iter() - .filter(|remote| { - remote.name.to_lowercase().contains(&query) - || remote.url.to_lowercase().contains(&query) - }) - .collect() - }; - - div() - .flex() - .flex_col() - .gap_2() - .child(remote_search_row(root.to_path_buf(), remote_search, theme, cx)) - .child( - div() - .flex() - .flex_col() - .gap_1() - .when(remotes.is_empty(), |this| { - this.child( - div() - .rounded(rems(0.375)) - .p_3() - .text_sm() - .text_color(theme.text_subtle) - .child(if has_remotes { - "No remotes match your search" - } else { - "No remotes" - }), - ) - }) - .when(!remotes.is_empty(), |this| { - this.children( - remotes - .into_iter() - .map(|remote| remote_row(root.to_path_buf(), remote, cx)), - ) - }), - ) - .into_any_element() -} - -fn remote_search_row( - root: PathBuf, - input: Entity, - theme: theme::Theme, - cx: &mut App, -) -> AnyElement { - let (remote_name, remote_url) = { - let state = cx.global::(); - ( - state.remote_name.as_ref().unwrap().clone(), - state.remote_url.as_ref().unwrap().clone(), - ) - }; - - div() - .flex() - .items_center() - .gap_2() - .mt(rems(0.25)) - .child(div().flex_1().min_w_0().child(search_input(input, theme))) - .child( - Button::new("git-new-remote") - .primary() - .label("New") - .on_click(move |_, window, cx| { - remote_name.update(cx, |input, cx| input.set_value("", window, cx)); - remote_url.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root.clone(), GitModal::CreateRemote, cx); - }), - ) - .into_any_element() -} - -fn create_remote_modal_body(cx: &mut App) -> AnyElement { - let (name, url) = { - let state = cx.global::(); - ( - state.remote_name.as_ref().unwrap().clone(), - state.remote_url.as_ref().unwrap().clone(), - ) - }; - - div() - .flex() - .flex_col() - .gap_3() - .child(input_row("Remote Name", name)) - .child(input_row("Remote URL", url)) - .into_any_element() -} - -fn create_remote_modal_footer(root: &Path, cx: &mut App) -> AnyElement { - let (name, url) = { - let state = cx.global::(); - ( - state.remote_name.as_ref().unwrap().clone(), - state.remote_url.as_ref().unwrap().clone(), - ) - }; - let root_cancel = root.to_path_buf(); - let root_create = root.to_path_buf(); - let cancel_name = name.clone(); - let cancel_url = url.clone(); - let create_name = name.clone(); - let create_url = url.clone(); - - div() - .flex() - .justify_end() - .gap_2() - .child(action_button( - "git-cancel-create-remote", - "Cancel", - false, - move |_, window, cx| { - cancel_name.update(cx, |input, cx| input.set_value("", window, cx)); - cancel_url.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root_cancel.clone(), GitModal::Remotes, cx); - }, - cx, - )) - .child( - Button::new("git-confirm-create-remote") - .primary() - .label("Create") - .on_click(move |_, _, cx| { - let name_value = create_name.read(cx).value().trim().to_string(); - let url_value = create_url.read(cx).value().trim().to_string(); - if name_value.is_empty() || url_value.is_empty() { - return; - } - run_modal_action_after_success_app( - root_create.clone(), - GitModal::Remotes, - move |root| kosmos_git::add_remote(root, &name_value, &url_value), - move |cx| { - cx.update_global::(|state, _| { - state.modal = Some(GitModal::Remotes) - }); - }, - cx, - ); - }), - ) - .into_any_element() -} diff --git a/crates/ui/src/tabs/git/parts/branches_modal.rs b/crates/ui/src/tabs/git/parts/branches_modal.rs deleted file mode 100644 index 3de0c37f..00000000 --- a/crates/ui/src/tabs/git/parts/branches_modal.rs +++ /dev/null @@ -1,341 +0,0 @@ -fn render_git_modal( - dialog: Dialog, - window: &mut Window, - cx: &mut App, -) -> Dialog { - let modal = cx - .try_global::() - .and_then(|state| Some((state.root.clone()?, state.modal?))); - let Some((root, modal_state)) = modal else { - return dialog.overlay(false).close_button(false); - }; - - let rem_size = window.rem_size(); - let viewport_size = window.viewport_size(); - let margin_top = viewport_size.height / 10.0; - let width = rems(30.0) - .to_pixels(rem_size) - .min((viewport_size.width - rems(2.0).to_pixels(rem_size)).max(rems(16.0).to_pixels(rem_size))); - let max_height = - (viewport_size.height - margin_top - margin_top).max(rems(12.0).to_pixels(rem_size)); - - match modal_state { - GitModal::Branches => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Git Branches") - .footer(modal_footer(close_modal_button(cx), cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(branches_modal_body(&root, cx)), - GitModal::CreateBranch => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Create Branch") - .footer(create_branch_modal_footer(&root, cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(create_branch_modal_body(cx)) - , - GitModal::Remotes => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Git Remotes") - .footer(modal_footer(close_modal_button(cx), cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(remotes_modal_body(&root, cx)), - GitModal::CreateRemote => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Create Remote") - .footer(create_remote_modal_footer(&root, cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(create_remote_modal_body(cx)), - GitModal::Stashes => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Git Stashes") - .footer(modal_footer(close_modal_button(cx), cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(stashes_modal_body(&root, cx)), - GitModal::Tags => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Git Tags") - .footer(modal_footer(close_modal_button(cx), cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(tags_modal_body(&root, cx)), - GitModal::CreateTag => dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title("Create Tag") - .footer(create_tag_modal_footer(&root, cx)) - .on_close(|_, _, cx| close_modal(cx)) - .child(create_tag_modal_body(cx)), - GitModal::ConfirmDiscardSelected => { - let selected_paths = selected_change_paths(cx); - let selected_count = selected_paths.len(); - confirm_git_action_modal( - dialog, - ConfirmGitActionModal { - title: "Discard Selected Changes", - message: format!( - "This will permanently discard {selected_count} selected working tree change{}. This action cannot be undone.", - plural(selected_count) - ), - button_id: "git-confirm-discard-selected", - button_label: "Discard Selected", - danger: true, - confirm: Rc::new(move |_, window, cx| { - close_modal(cx); - window.close_dialog(cx); - run_git_action_app( - root.clone(), - { - let selected_paths = selected_paths.clone(); - move |root| kosmos_git::discard_files(root, &selected_paths) - }, - cx, - ); - }), - }, - margin_top, - width, - max_height, - cx, - ) - } - GitModal::ConfirmDiscard => { - confirm_git_action_modal( - dialog, - ConfirmGitActionModal { - title: "Discard All Changes", - message: "This will permanently discard all tracked and untracked working tree changes. This action cannot be undone.".to_string(), - button_id: "git-confirm-discard", - button_label: "Discard All", - danger: true, - confirm: Rc::new(move |_, window, cx| { - close_modal(cx); - window.close_dialog(cx); - run_git_action_app(root.clone(), kosmos_git::discard_all_changes, cx); - }), - }, - margin_top, - width, - max_height, - cx, - ) - } - GitModal::ConfirmResolveConflicts => { - let (conflict_paths, stage_all_changes) = { - let state = cx.global::(); - ( - state.pending_conflict_paths.clone(), - state.pending_conflict_resolution_stages_all, - ) - }; - let conflict_count = conflict_paths.len(); - let (message, button_label) = if stage_all_changes { - ( - format!( - "This will stage all changes, including {conflict_count} conflicted file{}. Staging conflicted files tells Git the conflict{} resolved. Make sure the conflict markers are handled before continuing.", - plural(conflict_count), - plural(conflict_count) - ), - "Stage All", - ) - } else { - ( - format!( - "This will stage {conflict_count} conflicted file{} and tell Git the conflict{} resolved. Make sure the conflict markers are handled before continuing.", - plural(conflict_count), - plural(conflict_count) - ), - "Mark Resolved", - ) - }; - confirm_git_action_modal( - dialog, - ConfirmGitActionModal { - title: "Mark Conflicts Resolved", - message, - button_id: "git-confirm-resolve-conflicts", - button_label, - danger: false, - confirm: Rc::new(move |_, window, cx| { - close_modal(cx); - window.close_dialog(cx); - if stage_all_changes { - run_git_action_app(root.clone(), kosmos_git::stage_all, cx); - } else { - run_git_action_app( - root.clone(), - { - let conflict_paths = conflict_paths.clone(); - move |root| kosmos_git::stage_files(root, &conflict_paths) - }, - cx, - ); - } - }), - }, - margin_top, - width, - max_height, - cx, - ) - } - } -} - -struct ConfirmGitActionModal { - title: &'static str, - message: String, - button_id: &'static str, - button_label: &'static str, - danger: bool, - confirm: PopupMenuHandler, -} - -fn confirm_git_action_modal( - dialog: Dialog, - config: ConfirmGitActionModal, - margin_top: Pixels, - width: Pixels, - max_height: Pixels, - cx: &mut App, -) -> Dialog -{ - let _ = cx; - let confirm = config.confirm.clone(); - dialog - .margin_top(margin_top) - .w(width) - .max_h(max_height) - .overflow_hidden() - .title(config.title) - .footer( - div() - .flex() - .justify_end() - .gap_2() - .child(close_modal_button(cx)) - .child(action_button( - config.button_id, - config.button_label, - config.danger, - move |event, window, cx| confirm(event, window, cx), - cx, - )), - ) - .on_close(|_, _, cx| close_modal(cx)) - .child( - div() - .flex() - .flex_col() - .gap_2() - .text_sm() - .child(config.message), - ) -} - -fn branches_modal_body(root: &Path, cx: &mut App) -> AnyElement { - let (branch_search, branches) = { - let state = cx.global::(); - ( - state.branch_search.as_ref().unwrap().clone(), - state.branches.clone(), - ) - }; - let theme = *cx.theme(); - let query = branch_search.read(cx).value().trim().to_lowercase(); - let has_branches = !branches.is_empty(); - let branches = if query.is_empty() { - branches - } else { - branches - .into_iter() - .filter(|branch| { - branch.name.to_lowercase().contains(&query) - || (if branch.remote { "remote" } else { "local" }).contains(&query) - }) - .collect() - }; - - div() - .flex() - .flex_col() - .gap_2() - .child(branch_search_row(root.to_path_buf(), branch_search, theme, cx)) - .child( - div() - .flex() - .flex_col() - .gap_1() - .when(branches.is_empty(), |this| { - this.child( - div() - .rounded(rems(0.375)) - .p_3() - .text_sm() - .text_color(theme.text_subtle) - .child(if has_branches { - "No branches match your search" - } else { - "No branches" - }), - ) - }) - .when(!branches.is_empty(), |this| { - this.children( - branches - .into_iter() - .map(|branch| branch_row(root.to_path_buf(), branch, cx)), - ) - }), - ) - .into_any_element() -} - -fn branch_search_row( - root: PathBuf, - input: Entity, - theme: theme::Theme, - cx: &mut App, -) -> AnyElement { - let branch_name = cx - .global::() - .branch_name - .as_ref() - .unwrap() - .clone(); - - div() - .flex() - .items_center() - .gap_2() - .mt(rems(0.25)) - .child(div().flex_1().min_w_0().child(search_input(input, theme))) - .child( - Button::new("git-new-branch") - .primary() - .label("New") - .on_click(move |_, window, cx| { - branch_name.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root.clone(), GitModal::CreateBranch, cx); - }), - ) - .into_any_element() -} diff --git a/crates/ui/src/tabs/git/parts/change_tree.rs b/crates/ui/src/tabs/git/parts/change_tree.rs deleted file mode 100644 index 56514039..00000000 --- a/crates/ui/src/tabs/git/parts/change_tree.rs +++ /dev/null @@ -1,487 +0,0 @@ -fn change_list( - root: &Path, - summary: &RepositorySummary, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let change_tree = build_change_tree(&summary.files); - let Some(tree_state) = cx.global::().change_tree_state.clone() else { - return div().into_any_element(); - }; - - let items = change_tree_items(&change_tree, cx); - tree_state.update(cx, |state, cx| state.set_items(items, cx)); - - let file_changes = summary - .files - .iter() - .cloned() - .map(|change| (change.path.clone(), change)) - .collect::>(); - let root_path = root.to_path_buf(); - let delegate = cx.entity().clone(); - - div() - .id("git-change-list") - .relative() - .flex_1() - .min_h_0() - .bg(theme.bg_surface) - .child( - div() - .id("git-change-list-scroll") - .size_full() - .min_h_0() - .flex() - .flex_col() - .when(summary.files.is_empty(), |this| { - this.flex().flex_col().items_center().justify_center() - }) - .when(!summary.files.is_empty(), |this| { - this.child( - div() - .flex_none() - .px_4() - .pt_3() - .pb_2() - .text_xs() - .text_color(theme.text_subtle) - .child("TRACKED"), - ) - }) - .when(summary.files.is_empty(), |this| { - this.child( - div() - .text_sm() - .text_color(theme.text_subtle) - .child("No changes"), - ) - }) - .when(!summary.files.is_empty(), |this| { - this.child( - div() - .flex_1() - .min_h_0() - .w_full() - .child( - component_tree(&tree_state, move |ix, entry, _, _, cx| { - change_tree_row( - ix, - entry, - root_path.clone(), - file_changes.clone(), - delegate.clone(), - cx, - ) - }) - .size_full(), - ), - ) - }), - ) - .into_any_element() -} - -const CHANGE_DIR_PREFIX: &str = "dir:"; -const CHANGE_FILE_PREFIX: &str = "file:"; - -#[derive(Default)] -struct ChangeTreeNode { - name: String, - path: String, - dirs: std::collections::BTreeMap, - files: Vec, -} - -fn build_change_tree(files: &[FileChange]) -> ChangeTreeNode { - let mut root = ChangeTreeNode::default(); - for change in files { - let (dir_path, file_name) = change - .path - .rsplit_once('/') - .unwrap_or(("", change.path.as_str())); - let mut node = &mut root; - let mut current_path = String::new(); - if !dir_path.is_empty() { - for dir_name in dir_path.split('/') { - if !current_path.is_empty() { - current_path.push('/'); - } - current_path.push_str(dir_name); - node = node - .dirs - .entry(dir_name.to_string()) - .or_insert_with(|| ChangeTreeNode { - name: dir_name.to_string(), - path: current_path.clone(), - ..Default::default() - }); - } - } - let mut file = change.clone(); - file.path = if node.path.is_empty() { - file_name.to_string() - } else { - format!("{}/{}", node.path, file_name) - }; - node.files.push(file); - } - root -} - -fn change_tree_items( - node: &ChangeTreeNode, - cx: &mut Context, -) -> Vec { - let mut items = Vec::new(); - items.extend( - node.dirs - .values() - .map(|child| change_dir_tree_item(child, true, cx)), - ); - items.extend(node.files.iter().map(change_file_tree_item)); - items -} - -fn change_dir_tree_item( - node: &ChangeTreeNode, - keep_separate: bool, - cx: &mut Context, -) -> TreeItem { - let mut label = node.name.clone(); - let mut display_node = node; - while !keep_separate && display_node.files.is_empty() && display_node.dirs.len() == 1 { - let child = display_node.dirs.values().next().unwrap(); - label = format!("{label}/{}", child.name); - display_node = child; - } - - let is_expanded = !cx - .global::() - .collapsed_change_dirs - .contains(&display_node.path); - let children = display_node - .dirs - .values() - .map(|child| change_dir_tree_item(child, false, cx)) - .chain(display_node.files.iter().map(change_file_tree_item)); - - TreeItem::new(format!("{CHANGE_DIR_PREFIX}{}", display_node.path), label) - .expanded(is_expanded) - .children(children) -} - -fn change_file_tree_item(change: &FileChange) -> TreeItem { - TreeItem::new( - format!("{CHANGE_FILE_PREFIX}{}", change.path), - change - .path - .rsplit_once('/') - .map(|(_, name)| name.to_string()) - .unwrap_or_else(|| change.path.clone()), - ) -} - -fn change_tree_row( - ix: usize, - entry: &TreeEntry, - root: PathBuf, - file_changes: std::collections::BTreeMap, - delegate: Entity, - cx: &mut App, -) -> ListItem { - let item = entry.item(); - let id = item.id.as_str(); - if let Some(path) = id.strip_prefix(CHANGE_DIR_PREFIX) { - let path = path.to_string(); - return change_tree_dir_row(ix, entry, root, path, cx); - } - - let path = id.strip_prefix(CHANGE_FILE_PREFIX).unwrap_or(id); - let change = file_changes.get(path).cloned(); - change_tree_file_row(ix, entry, root, change, delegate, cx) -} - -fn change_tree_dir_row( - ix: usize, - entry: &TreeEntry, - root: PathBuf, - path: String, - cx: &mut App, -) -> ListItem { - let stats = cx - .global::() - .summary - .as_ref() - .map(|summary| node_stats_for_path(&summary.files, &path)) - .unwrap_or_default(); - let icon = if entry.is_expanded() { - IconName::FolderOpened - } else { - IconName::Folder - }; - let toggle_path = path.clone(); - - ListItem::new(ix) - .h(rems(CHANGE_ROW_HEIGHT_REM)) - .px(rems(CHANGE_ROW_PADDING_REM)) - .py_0() - .child( - div() - .w_full() - .min_w_0() - .flex() - .items_center() - .gap_1() - .child(change_row_label( - entry.depth(), - icon, - entry.item().label.clone(), - cx, - )) - .child(stage_checkbox_app( - SharedString::from(format!("git-folder-toggle:{path}")), - stats.staged == stats.total, - stats.conflict_paths.clone(), - root.clone(), - path.clone(), - cx, - )), - ) - .on_click(move |_, _, cx| toggle_change_dir_app(&toggle_path, cx)) -} - -fn change_tree_file_row( - ix: usize, - entry: &TreeEntry, - root: PathBuf, - change: Option, - delegate: Entity, - cx: &mut App, -) -> ListItem { - let theme = *cx.theme(); - let path = change - .as_ref() - .map(|change| change.path.clone()) - .unwrap_or_else(|| entry.item().label.to_string()); - let icon_name = icon_for_git_file(Path::new(&path)); - let icon_color = change - .as_ref() - .map(|change| match change.kind { - FileChangeKind::Created => theme.success, - FileChangeKind::Modified => theme.text_muted, - FileChangeKind::Deleted => theme.danger, - FileChangeKind::Renamed => theme.accent_secondary, - FileChangeKind::Conflicted => theme.accent_secondary, - }) - .unwrap_or(theme.text_muted); - let path_for_click = path.clone(); - let root_for_click = root.clone(); - let delegate_for_click = delegate.clone(); - - ListItem::new(ix) - .h(rems(CHANGE_ROW_HEIGHT_REM)) - .px(rems(CHANGE_ROW_PADDING_REM)) - .py_0() - .child( - div() - .w_full() - .min_w_0() - .flex() - .items_center() - .gap_1() - .child(change_row_label_with_color( - entry.depth(), - icon_name, - icon_color.into(), - entry.item().label.clone(), - cx, - )) - .when_some(change, |this, change| { - this.child( - div() - .flex() - .flex_none() - .items_center() - .gap_1() - .child(file_diff_stats(&change, cx)) - .child(stage_checkbox_app( - SharedString::from(format!("git-file-toggle:{}", change.path)), - change.staged, - (change.kind == FileChangeKind::Conflicted) - .then(|| vec![change.path.clone()]) - .unwrap_or_default(), - root.clone(), - change.path.clone(), - cx, - )), - ) - }), - ) - .on_click(move |_, _, cx| { - let root = root_for_click.clone(); - let path = path_for_click.clone(); - delegate_for_click.update(cx, |delegate, cx| { - delegate.open_diff_for_file(root, path, cx) - }); - }) -} - -fn file_diff_stats(change: &FileChange, cx: &mut App) -> AnyElement { - let theme = *cx.theme(); - div() - .flex() - .flex_none() - .items_center() - .gap_1() - .child(diff_stat_text(format!("+{}", change.insertions), theme.success)) - .child(diff_stat_text(format!("-{}", change.deletions), theme.danger)) - .into_any_element() -} - -#[derive(Default)] -struct ChangeNodeStats { - staged: usize, - total: usize, - conflict_paths: Vec, -} - -fn node_stats_for_path(files: &[FileChange], path: &str) -> ChangeNodeStats { - let prefix = format!("{path}/"); - files - .iter() - .filter(|file| file.path.starts_with(&prefix)) - .fold(ChangeNodeStats::default(), |mut stats, file| { - stats.total += 1; - if file.staged { - stats.staged += 1; - } - if file.kind == FileChangeKind::Conflicted { - stats.conflict_paths.push(file.path.clone()); - } - stats - }) -} - -fn change_row_label( - depth: usize, - icon_name: IconName, - label: SharedString, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - change_row_label_with_color(depth, icon_name, theme.text_muted, label, cx) -} - -fn change_row_label_with_color( - depth: usize, - icon_name: IconName, - icon_color: gpui::Rgba, - label: SharedString, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_w_0() - .flex() - .items_center() - .child(change_indent_guides(depth, theme)) - .child( - div() - .w(rems(CHANGE_ICON_WIDTH_REM)) - .h(rems(CHANGE_ROW_HEIGHT_REM)) - .flex_none() - .flex() - .items_center() - .justify_center() - .child(Icon::new(icon_name).size_rem(0.875).color(icon_color)), - ) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .pl(rems(CHANGE_LABEL_PADDING_REM)) - .text_sm() - .text_color(theme.text) - .child(label), - ) - .into_any_element() -} - -fn stage_checkbox_app( - id: SharedString, - staged: bool, - conflict_paths: Vec, - root: PathBuf, - path: String, - _cx: &mut App, -) -> AnyElement { - Checkbox::new(id) - .large() - .flex_none() - .tab_stop(false) - .checked(staged) - .on_click(move |_: &bool, _, cx| { - cx.stop_propagation(); - let path = path.clone(); - if staged { - run_git_action_app( - root.clone(), - move |root| kosmos_git::unstage_file(root, &path), - cx, - ); - } else if !conflict_paths.is_empty() { - open_resolve_conflicts_modal_app(root.clone(), conflict_paths.clone(), false, cx); - } else { - run_git_action_app( - root.clone(), - move |root| kosmos_git::stage_file(root, &path), - cx, - ); - } - }) - .into_any_element() -} - -fn change_indent_guides(depth: usize, theme: theme::Theme) -> AnyElement { - if depth == 0 { - return div().flex_none().into_any_element(); - } - - let mut row = div().flex().flex_none().h(rems(CHANGE_ROW_HEIGHT_REM)); - for _ in 0..depth { - row = row.child( - div() - .relative() - .w(rems(CHANGE_INDENT_REM)) - .h(rems(CHANGE_ROW_HEIGHT_REM)) - .flex_none() - .child( - div() - .absolute() - .left(rems(CHANGE_GUIDE_OFFSET_REM)) - .top_0() - .bottom_0() - .w(rems(CHANGE_GUIDE_WIDTH_REM)) - .bg(gpui::Hsla::from(theme.text).opacity(0.1)), - ), - ); - } - row.into_any_element() -} - -fn icon_for_git_file(path: &Path) -> IconName { - if let Some(name) = path.file_name().and_then(|name| name.to_str()) - && let Some(icon) = IconName::for_file_name(name) - { - return icon; - } - - language::from_path(path) - .and_then(|id| IconName::for_language(id.as_str())) - .unwrap_or(IconName::File) -} diff --git a/crates/ui/src/tabs/git/parts/modal_rows_and_buttons.rs b/crates/ui/src/tabs/git/parts/modal_rows_and_buttons.rs deleted file mode 100644 index 80b0b8bb..00000000 --- a/crates/ui/src/tabs/git/parts/modal_rows_and_buttons.rs +++ /dev/null @@ -1,131 +0,0 @@ -fn tag_row(root: PathBuf, tag: Tag, cx: &mut App) -> AnyElement { - let name = tag.name.clone(); - list_row( - tag.name, - tag.message, - true, - move |_, _, cx| { - let name = name.clone(); - run_modal_action_app( - root.clone(), - GitModal::Tags, - move |root| kosmos_git::delete_tag(root, &name), - cx, - ); - }, - cx, - ) -} - -fn list_row( - title: String, - subtitle: String, - highlight_on_hover: bool, - delete: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex() - .items_center() - .justify_between() - .gap_2() - .rounded(rems(0.375)) - .p_2() - .when(highlight_on_hover, |this| this.hover(move |this| this.bg(theme.bg_hover))) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_0p5() - .child(div().text_sm().text_color(theme.text).child(title)) - .child( - div() - .text_xs() - .text_color(theme.text_subtle) - .child(subtitle), - ), - ) - .child(delete_button("git-delete-list-item", delete, cx)) - .into_any_element() -} - -fn delete_button( - id: impl Into, - listener: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - icon_action_button(id, IconName::Trash, theme.danger, listener, cx) -} - -fn icon_action_button( - id: impl Into, - icon: IconName, - color: gpui::Rgba, - listener: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - Button::new(id) - .ghost() - .small() - .size(rems(1.625)) - .tab_stop(false) - .text_color(color) - .hover(move |this| this.bg(theme.bg_hover)) - .icon(component_icon(icon)) - .on_click(move |event, window, cx| { - cx.stop_propagation(); - listener(event, window, cx); - }) - .into_any_element() -} - -fn modal_footer(button: AnyElement, _cx: &mut App) -> AnyElement { - div().flex().justify_end().child(button).into_any_element() -} - -fn close_modal_button(cx: &mut App) -> AnyElement { - action_button( - "git-close-modal", - "Close", - false, - |_, window, cx| { - close_modal(cx); - window.close_dialog(cx); - }, - cx, - ) -} - -fn action_button( - id: impl Into, - label: &'static str, - danger: bool, - listener: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, - cx: &mut App, -) -> AnyElement { - let _ = cx; - Button::new(id) - .secondary() - .when(danger, |this| this.danger()) - .label(label) - .on_click(listener) - .into_any_element() -} - -fn commit_button( - enabled: bool, - listener: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, - cx: &mut Context, -) -> AnyElement { - let _ = cx; - Button::new("git-commit-tracked") - .primary() - .disabled(!enabled) - .label("Commit Tracked") - .on_click(listener) - .into_any_element() -} diff --git a/crates/ui/src/tabs/git/parts/panels.rs b/crates/ui/src/tabs/git/parts/panels.rs deleted file mode 100644 index e4983e3a..00000000 --- a/crates/ui/src/tabs/git/parts/panels.rs +++ /dev/null @@ -1,555 +0,0 @@ -fn loading_state(cx: &mut Context) -> AnyElement { - empty_panel("Loading Git status", cx) -} - -fn commit_panel( - root: &Path, - summary: Option<&RepositorySummary>, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let commit_message = cx - .global::() - .commit_message - .as_ref() - .unwrap() - .clone(); - let has_staged = summary.is_some_and(|summary| summary.files.iter().any(|file| file.staged)); - let root_commit = root.to_path_buf(); - let message_input = commit_message.clone(); - - div() - .flex_none() - .border_t_1() - .border_color(theme.border_subtle) - .bg(theme.bg_surface) - .child( - div() - .min_w_0() - .w_full() - .h(rems(COMMIT_PANEL_HEIGHT_REM)) - .flex() - .flex_col() - .pb(rems(COMMIT_CONTROLS_INSET_BOTTOM_REM)) - .child(sync_action_panel(root, summary, cx)) - .child( - Input::new(&commit_message) - .h(rems(COMMIT_MESSAGE_HEIGHT_REM)) - .appearance(false) - .px(rems(COMMIT_MESSAGE_PADDING_X_REM)) - .pt(rems(COMMIT_MESSAGE_PADDING_TOP_REM)) - .pb(rems(COMMIT_MESSAGE_PADDING_BOTTOM_REM)), - ) - .child( - div() - .flex_none() - .w_full() - .px(rems(COMMIT_CONTROLS_INSET_X_REM)) - .flex() - .items_center() - .justify_end() - .child(commit_button( - has_staged, - cx.listener(move |_, _, window, cx| { - let message = message_input.read(cx).value().to_string(); - commit_tracked( - root_commit.clone(), - message, - message_input.clone(), - window, - cx, - ); - }), - cx, - )), - ), - ) - .into_any_element() -} - -fn sync_action_panel( - root: &Path, - summary: Option<&RepositorySummary>, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let (action, sync_action_running) = { - let state = cx.global::(); - (state.last_sync_action, state.sync_action_running.is_some()) - }; - let branch_sync = summary.map(|summary| summary.branch_sync); - let branch = summary - .and_then(|summary| summary.branch.as_deref()) - .unwrap_or("Detached HEAD") - .to_string(); - let root_branch = root.to_path_buf(); - let root_action = root.to_path_buf(); - let root_more = root.to_path_buf(); - - div() - .id("git-sync-panel") - .flex_none() - .w_full() - .border_b_1() - .border_color(theme.border_subtle) - .px(rems(SYNC_PANEL_INSET_X_REM)) - .py_1p5() - .flex() - .items_center() - .justify_between() - .gap_2() - .child( - div() - .min_w_0() - .flex_1() - .flex() - .child(branch_button(root_branch, branch, cx)), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_1() - .child(sync_action_button( - "git-sync-primary-action", - action, - branch_sync, - sync_action_running, - move |_, _, cx| run_sync_action(root_action.clone(), action, false, cx), - cx, - )) - .child(sync_more_button(root_more, sync_action_running, cx)), - ) - .into_any_element() -} - -fn branch_button( - root: PathBuf, - branch: String, - cx: &mut Context, -) -> AnyElement { - Button::new("git-current-branch") - .secondary() - .min_w_0() - .max_w(rems(12.0)) - .on_click(cx.listener(move |_, _, _, cx| { - open_modal(root.clone(), GitModal::Branches, cx); - })) - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_1p5() - .child(component_icon(IconName::SourceControl).small()) - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(branch), - ), - ) - .into_any_element() -} - -fn sync_action_button( - id: &'static str, - action: GitSyncAction, - branch_sync: Option, - loading: bool, - listener: impl Fn(&ClickEvent, &mut Window, &mut Context) + 'static, - cx: &mut Context, -) -> AnyElement { - let branch_sync = branch_sync.unwrap_or_default(); - let indicators = (!branch_sync.is_synced()).then(|| sync_status_indicators(branch_sync)); - let tooltip = sync_status_tooltip(branch_sync); - - Button::new(id) - .secondary() - .when(action.is_danger(), |this| this.danger()) - .icon(component_icon(action.icon())) - .loading_icon(component_icon(IconName::Refresh)) - .label(action.label()) - .loading(loading) - .when_some(indicators, |this, indicators| this.child(indicators)) - .when_some(tooltip, |this, tooltip| this.tooltip(tooltip)) - .on_click(cx.listener(move |_, event: &ClickEvent, window, cx| { - cx.stop_propagation(); - listener(event, window, cx); - })) - .into_any_element() -} - -fn sync_status_indicators(branch_sync: BranchSyncStatus) -> AnyElement { - div() - .flex_none() - .flex() - .items_center() - .gap_1() - .when(branch_sync.ahead > 0, |this| { - this.child(sync_status_indicator(IconName::ArrowUp, branch_sync.ahead)) - }) - .when(branch_sync.behind > 0, |this| { - this.child(sync_status_indicator(IconName::ArrowDown, branch_sync.behind)) - }) - .into_any_element() -} - -fn sync_status_indicator(icon: IconName, count: usize) -> AnyElement { - div() - .flex() - .items_center() - .gap_0p5() - .opacity(0.6) - .child(component_icon(icon).xsmall()) - .child(div().text_xs().child(count.to_string())) - .into_any_element() -} - -fn sync_status_tooltip(branch_sync: BranchSyncStatus) -> Option { - if branch_sync.is_synced() { - return None; - } - - let mut parts = Vec::new(); - if branch_sync.ahead > 0 { - parts.push(format!( - "{} commit{} to push", - branch_sync.ahead, - plural(branch_sync.ahead) - )); - } - if branch_sync.behind > 0 { - parts.push(format!( - "{} commit{} to pull", - branch_sync.behind, - plural(branch_sync.behind) - )); - } - Some(parts.join(", ")) -} - -fn sync_more_button( - root: PathBuf, - disabled: bool, - cx: &mut Context, -) -> AnyElement { - let actions = GitSyncAction::ALL - .into_iter() - .map(|action| { - let root = root.clone(); - let listener: PopupMenuHandler = Rc::new( - cx.listener(move |_, _, _, cx| run_sync_action(root.clone(), action, true, cx)), - ); - ( - action.icon(), - action.label(), - true, - action.is_danger(), - listener, - ) - }) - .collect::>(); - - Button::new("git-sync-more") - .secondary() - .tab_stop(false) - .disabled(disabled) - .icon(component_icon(IconName::Ellipsis)) - .dropdown_menu_with_anchor(Anchor::BottomRight, move |menu, window, _| { - let menu_width = rems(11.0).to_pixels(window.rem_size()); - actions.iter().fold( - menu.min_w(menu_width), - |menu, (icon, label, enabled, danger, listener)| { - menu.item(popup_menu_item( - *icon, - label, - *enabled, - *danger, - listener.clone(), - )) - }, - ) - }) - .into_any_element() -} - -fn latest_commit_panel( - commit: CommitInfo, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let subject = commit - .subject - .lines() - .next() - .unwrap_or_default() - .to_string(); - if subject.is_empty() { - return div().into_any_element(); - } - - div() - .flex_none() - .border_t_1() - .border_color(theme.border_subtle) - .bg(theme.bg_surface) - .px_3() - .py_2() - .flex() - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .text_xs() - .text_color(theme.text_subtle) - .child(SharedString::from(subject)), - ) - .into_any_element() -} - -fn empty_panel( - message: &'static str, - cx: &mut Context, -) -> AnyElement { - let _ = cx; - Alert::new("git-empty-panel", message) - .with_size(Size::Small) - .icon(component_icon(IconName::SourceControl)) - .into_any_element() -} - -fn init_repository_panel( - root: &Path, - cx: &mut Context, -) -> AnyElement { - let root = root.to_path_buf(); - div() - .flex_1() - .min_h_0() - .flex() - .items_center() - .justify_center() - .child( - Button::new("git-init-repository") - .secondary() - .label("Initialize Repository") - .on_click(cx.listener(move |_, _, _, cx| { - run_git_action(root.clone(), kosmos_git::init, cx); - })), - ) - .into_any_element() -} - -fn diff_stats( - summary: &RepositorySummary, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex() - .items_center() - .gap_1() - .when(summary.insertions > 0, |this| { - this.child(diff_stat_text(format!("+{}", summary.insertions), theme.success)) - }) - .when(summary.deletions > 0, |this| { - this.child(diff_stat_text(format!("-{}", summary.deletions), theme.danger)) - }) - .into_any_element() -} - -fn diff_stat_text(label: impl Into, color: gpui::Rgba) -> AnyElement { - div() - .text_xs() - .text_color(color) - .child(label.into()) - .into_any_element() -} - -fn icon_button( - id: &'static str, - icon: IconName, - tooltip: Option<&'static str>, - listener: impl Fn(&ClickEvent, &mut Window, &mut Context) + 'static, - cx: &mut Context, -) -> AnyElement { - Button::new(id) - .ghost() - .small() - .tab_stop(false) - .size(rems(1.375)) - .icon(ComponentIcon::empty().path(icon.path())) - .when_some(tooltip, |this, tooltip| this.tooltip(tooltip)) - .on_click(cx.listener(move |_, event: &ClickEvent, window, cx| { - cx.stop_propagation(); - listener(event, window, cx); - })) - .into_any_element() -} - -fn more_button( - root: &Path, - cx: &mut Context, -) -> AnyElement { - let root_branches = root.to_path_buf(); - let root_remotes = root.to_path_buf(); - let root_stashes = root.to_path_buf(); - let root_tags = root.to_path_buf(); - let root_discard_selected = root.to_path_buf(); - let root_discard = root.to_path_buf(); - let has_selected_changes = cx - .global::() - .summary - .as_ref() - .is_some_and(|summary| summary.files.iter().any(|file| file.staged)); - - let items = vec![ - ( - IconName::SourceControl, - "Branches", - true, - false, - Rc::new(cx.listener(move |_, _, _, cx| { - open_modal(root_branches.clone(), GitModal::Branches, cx) - })) as PopupMenuHandler, - ), - ( - IconName::Server, - "Remotes", - true, - false, - Rc::new(cx.listener(move |_, _, _, cx| { - open_modal(root_remotes.clone(), GitModal::Remotes, cx) - })) as PopupMenuHandler, - ), - ( - IconName::Archive, - "Stashes", - true, - false, - Rc::new(cx.listener(move |_, _, _, cx| { - open_modal(root_stashes.clone(), GitModal::Stashes, cx) - })) as PopupMenuHandler, - ), - ( - IconName::Tag, - "Tags", - true, - false, - Rc::new( - cx.listener(move |_, _, _, cx| open_modal(root_tags.clone(), GitModal::Tags, cx)), - ) as PopupMenuHandler, - ), - ]; - let danger_items = vec![ - ( - IconName::Trash, - "Discard Selected Changes", - has_selected_changes, - true, - Rc::new(cx.listener(move |_, _, _, cx| { - open_modal( - root_discard_selected.clone(), - GitModal::ConfirmDiscardSelected, - cx, - ) - })) as PopupMenuHandler, - ), - ( - IconName::Trash, - "Discard All Changes", - true, - true, - Rc::new(cx.listener(move |_, _, _, cx| { - open_modal(root_discard.clone(), GitModal::ConfirmDiscard, cx) - })) as PopupMenuHandler, - ), - ]; - - Button::new("git-more") - .ghost() - .small() - .tab_stop(false) - .size(rems(1.375)) - .icon(component_icon(IconName::Ellipsis)) - .dropdown_menu_with_anchor(Anchor::TopLeft, move |menu, window, _| { - let menu_width = rems(11.0).to_pixels(window.rem_size()); - let menu = items.iter().fold( - menu.min_w(menu_width), - |menu, (icon, label, enabled, danger, listener)| { - menu.item(popup_menu_item( - *icon, - label, - *enabled, - *danger, - listener.clone(), - )) - }, - ); - danger_items.iter().fold( - menu.separator(), - |menu, (icon, label, enabled, danger, listener)| { - menu.item(popup_menu_item( - *icon, - label, - *enabled, - *danger, - listener.clone(), - )) - }, - ) - }) - .into_any_element() -} - -fn popup_menu_item( - icon: IconName, - label: &'static str, - enabled: bool, - danger: bool, - listener: PopupMenuHandler, -) -> PopupMenuItem { - PopupMenuItem::element(move |_, cx| { - let theme = *cx.theme(); - let text_color = if !enabled { - theme.text_subtle - } else if danger { - theme.danger - } else { - theme.text - }; - let icon_color = if !enabled { - theme.text_subtle - } else if danger { - theme.danger - } else { - theme.text_muted - }; - - div() - .w_full() - .flex() - .items_center() - .gap_2() - .text_color(text_color) - .child( - div() - .w(rems(1.0)) - .flex() - .items_center() - .justify_center() - .text_color(icon_color) - .child(component_icon(icon).small()), - ) - .child(label) - }) - .disabled(!enabled) - .on_click(move |event, window, cx| listener(event, window, cx)) -} diff --git a/crates/ui/src/tabs/git/parts/state_actions.rs b/crates/ui/src/tabs/git/parts/state_actions.rs deleted file mode 100644 index 81989dda..00000000 --- a/crates/ui/src/tabs/git/parts/state_actions.rs +++ /dev/null @@ -1,896 +0,0 @@ -fn empty_state( - message: &'static str, - cx: &mut Context, -) -> AnyElement { - centered_state( - super::icon_for_kind(registry::GIT.id), - message.to_string(), - cx, - ) -} - -fn centered_state( - icon: IconName, - message: String, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .bg(theme.bg_surface) - .text_color(theme.text_subtle) - .child(Icon::new(icon).size(28.0).color(theme.text_muted)) - .child(div().text_sm().child(message)) - .into_any_element() -} - -fn ensure_state( - window: &mut Window, - cx: &mut Context, -) { - if cx.try_global::().is_none() { - let commit_message = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) - .placeholder("Commit message") - }); - let branch_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search branches")); - let branch_name = cx.new(|cx| InputState::new(window, cx).placeholder("feature/my-branch")); - let tag_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search tags")); - let remote_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search remotes")); - let remote_name = cx.new(|cx| InputState::new(window, cx).placeholder("origin")); - let remote_url = cx.new(|cx| InputState::new(window, cx).placeholder("https://github.com/user/repo.git")); - let tag_name = cx.new(|cx| InputState::new(window, cx).placeholder("v1.0.0")); - let tag_message = cx.new(|cx| InputState::new(window, cx).placeholder("Release notes")); - let tag_sha = cx.new(|cx| InputState::new(window, cx).placeholder("HEAD")); - let change_tree_state = cx.new(|cx| TreeState::new(cx)); - cx.set_global(GitUiState { - commit_message: Some(commit_message), - branch_search: Some(branch_search), - branch_name: Some(branch_name), - tag_search: Some(tag_search), - remote_search: Some(remote_search), - remote_name: Some(remote_name), - remote_url: Some(remote_url), - tag_name: Some(tag_name), - tag_message: Some(tag_message), - tag_sha: Some(tag_sha), - change_tree_state: Some(change_tree_state), - ..Default::default() - }); - } -} - -fn open_modal( - root: PathBuf, - modal: GitModal, - cx: &mut Context, -) { - cx.update_global::(|state, _| { - state.root = Some(root.clone()); - state.modal = Some(modal); - state.last_error = None; - state.pending_conflict_paths.clear(); - state.pending_conflict_resolution_stages_all = false; - }); - refresh_modal_data(root, modal, cx); - cx.notify(); -} - -fn open_modal_app(root: PathBuf, modal: GitModal, cx: &mut App) { - cx.update_global::(|state, _| { - state.root = Some(root.clone()); - state.modal = Some(modal); - state.last_error = None; - state.pending_conflict_paths.clear(); - state.pending_conflict_resolution_stages_all = false; - }); - refresh_modal_data_app(root, modal, cx); - cx.refresh_windows(); -} - -fn close_modal(cx: &mut App) { - cx.update_global::(|state, _| { - state.modal = None; - state.pending_conflict_paths.clear(); - state.pending_conflict_resolution_stages_all = false; - }); -} - -fn stage_all_changes(root: PathBuf, cx: &mut Context) { - let conflict_paths = cx - .global::() - .summary - .as_ref() - .map(conflict_paths_in_summary) - .unwrap_or_default(); - if conflict_paths.is_empty() { - run_git_action(root, kosmos_git::stage_all, cx); - } else { - open_resolve_conflicts_modal(root, conflict_paths, true, cx); - } -} - -fn open_resolve_conflicts_modal( - root: PathBuf, - conflict_paths: Vec, - stage_all_changes: bool, - cx: &mut Context, -) { - if conflict_paths.is_empty() { - return; - } - cx.update_global::(|state, _| { - state.root = Some(root); - state.pending_conflict_paths = conflict_paths; - state.pending_conflict_resolution_stages_all = stage_all_changes; - state.modal = Some(GitModal::ConfirmResolveConflicts); - state.last_error = None; - }); - cx.notify(); -} - -fn conflict_paths_in_summary(summary: &RepositorySummary) -> Vec { - summary - .files - .iter() - .filter(|file| file.kind == FileChangeKind::Conflicted) - .map(|file| file.path.clone()) - .collect() -} - -fn selected_change_paths(cx: &mut App) -> Vec { - cx.global::() - .summary - .as_ref() - .map(|summary| { - summary - .files - .iter() - .filter(|file| file.staged) - .map(|file| file.path.clone()) - .collect() - }) - .unwrap_or_default() -} - -fn toggle_stash(id: &str, cx: &mut App) { - cx.update_global::(|state, _| { - if !state.expanded_stashes.remove(id) { - state.expanded_stashes.insert(id.to_string()); - } - }); - cx.refresh_windows(); -} - -fn toggle_change_dir_app(path: &str, cx: &mut App) { - cx.update_global::(|state, _| { - if !state.collapsed_change_dirs.remove(path) { - state.collapsed_change_dirs.insert(path.to_string()); - } - }); - cx.refresh_windows(); -} - -fn open_resolve_conflicts_modal_app( - root: PathBuf, - conflict_paths: Vec, - stage_all_changes: bool, - cx: &mut App, -) { - if conflict_paths.is_empty() { - return; - } - cx.update_global::(|state, _| { - state.root = Some(root); - state.pending_conflict_paths = conflict_paths; - state.pending_conflict_resolution_stages_all = stage_all_changes; - state.modal = Some(GitModal::ConfirmResolveConflicts); - state.last_error = None; - }); - cx.refresh_windows(); -} - -fn refresh_modal_data( - root: PathBuf, - modal: GitModal, - cx: &mut Context, -) { - cx.spawn(async move |this, cx| match modal { - GitModal::Branches => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_branches(root) }) - .await; - let _ = this.update(cx, |_, cx| { - apply_modal_list_result(modal, result.map(ModalList::Branches), cx); - }); - } - GitModal::Remotes => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_remotes(root) }) - .await; - let _ = this.update(cx, |_, cx| { - apply_modal_list_result(modal, result.map(ModalList::Remotes), cx); - }); - } - GitModal::Stashes => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_stashes(root) }) - .await; - let _ = this.update(cx, |_, cx| { - apply_modal_list_result(modal, result.map(ModalList::Stashes), cx); - }); - } - GitModal::Tags => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_tags(root) }) - .await; - let _ = this.update(cx, |_, cx| { - apply_modal_list_result(modal, result.map(ModalList::Tags), cx); - }); - } - GitModal::CreateBranch - | GitModal::CreateRemote - | GitModal::CreateTag - | GitModal::ConfirmDiscardSelected - | GitModal::ConfirmDiscard - | GitModal::ConfirmResolveConflicts => {} - }) - .detach(); -} - -fn refresh_modal_data_app(root: PathBuf, modal: GitModal, cx: &mut App) { - cx.spawn(async move |cx| { - match modal { - GitModal::Branches => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_branches(root) }) - .await; - let _ = cx.update(|cx| { - apply_modal_list_result_app(modal, result.map(ModalList::Branches), cx); - }); - } - GitModal::Remotes => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_remotes(root) }) - .await; - let _ = cx.update(|cx| { - apply_modal_list_result_app(modal, result.map(ModalList::Remotes), cx); - }); - } - GitModal::Stashes => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_stashes(root) }) - .await; - let _ = cx.update(|cx| { - apply_modal_list_result_app(modal, result.map(ModalList::Stashes), cx); - }); - } - GitModal::Tags => { - let result = cx - .background_executor() - .spawn(async move { kosmos_git::list_tags(root) }) - .await; - let _ = cx.update(|cx| { - apply_modal_list_result_app(modal, result.map(ModalList::Tags), cx); - }); - } - GitModal::CreateBranch - | GitModal::CreateRemote - | GitModal::CreateTag - | GitModal::ConfirmDiscardSelected - | GitModal::ConfirmDiscard - | GitModal::ConfirmResolveConflicts => {} - } - }) - .detach(); -} - -enum ModalList { - Branches(Vec), - Remotes(Vec), - Stashes(Vec), - Tags(Vec), -} - -fn run_modal_action_app( - root: PathBuf, - modal: GitModal, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - cx: &mut App, -) { - run_modal_action_after_success_app(root, modal, action, |_| {}, cx); -} - -fn run_modal_action_after_success_app( - root: PathBuf, - modal: GitModal, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - on_success: impl FnOnce(&mut App) + 'static, - cx: &mut App, -) { - clear_error(cx); - cx.update_global::(|state, _| state.loading = true); - cx.refresh_windows(); - - cx.spawn(async move |cx| { - let action_root = root.clone(); - let result = cx - .background_executor() - .spawn(async move { action(action_root) }) - .await; - let _ = cx.update(|cx| match result { - Ok(()) => { - on_success(cx); - refresh_modal_data_app(root.clone(), modal, cx); - refresh_summary_app(root, true, false, cx); - } - Err(error) => { - cx.update_global::(|state, _| { - state.loading = false; - state.last_error = None; - }); - show_git_error(cx, "Git action failed", git_error_message(error)); - cx.refresh_windows(); - } - }); - }) - .detach(); -} - -fn spawn_git_action( - root: PathBuf, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - on_success: impl FnOnce(PathBuf, &mut Context) + 'static, - on_error: impl FnOnce(kosmos_git::Error, &mut Context) + 'static, - cx: &mut Context, -) { - cx.spawn(async move |this, cx| { - let action_root = root.clone(); - let result = cx - .background_executor() - .spawn(async move { action(action_root) }) - .await; - let _ = this.update(cx, |_, cx| match result { - Ok(()) => on_success(root, cx), - Err(error) => on_error(error, cx), - }); - }) - .detach(); -} - -fn apply_git_action_error( - error: kosmos_git::Error, - cx: &mut Context, -) { - cx.update_global::(|state, _| { - state.loading = false; - state.last_error = Some(error.to_string()); - }); - cx.notify(); -} - -enum DiffRefreshMode { - Full, - Paths(Vec), -} - -const TARGETED_DIFF_REFRESH_PATH_LIMIT: usize = 128; - -fn ensure_summary(root: &Path, cx: &mut Context) { - ensure_summary_watch(root, cx); - - let needs_refresh = { - let state = cx.global::(); - state.root.as_deref() != Some(root) - || (!state.loading && state.summary.is_none() && !state.can_initialize_repository) - }; - if needs_refresh { - refresh_summary(root.to_path_buf(), false, true, cx); - } -} - -fn ensure_summary_watch(root: &Path, cx: &mut Context) { - let generation = cx.update_global::(|state, _| { - if state.root.as_deref() == Some(root) && state.watch_task.is_some() { - return None; - } - - state.watch_generation = state.watch_generation.wrapping_add(1); - Some(state.watch_generation) - }); - - let Some(generation) = generation else { - return; - }; - - let root = root.to_path_buf(); - let task = cx.spawn(async move |this, cx| { - loop { - cx.background_executor() - .timer(Duration::from_millis(750)) - .await; - - let refresh_root = root.clone(); - let result = cx - .background_executor() - .spawn(async move { RepositorySummary::discover(refresh_root) }) - .await; - - let should_continue = this - .update(cx, |_, cx| { - apply_watched_summary(&root, generation, result, cx) - }) - .unwrap_or(false); - - if !should_continue { - break; - } - } - }); - - cx.update_global::(|state, _| { - state.watch_task = Some(task); - }); -} - -fn apply_watched_summary( - root: &Path, - generation: u64, - result: Result, - cx: &mut Context, -) -> bool { - let mut changed = false; - let mut diff_refresh = None; - let should_continue = cx.update_global::(|state, _| { - if state.watch_generation != generation || state.root.as_deref() != Some(root) { - return false; - } - - match result { - Ok(summary) => { - diff_refresh = if state.last_error.is_some() || state.can_initialize_repository { - Some(DiffRefreshMode::Full) - } else { - summary_diff_refresh(state.summary.as_ref(), &summary) - }; - changed = state.summary.as_ref() != Some(&summary) - || state.last_error.is_some() - || state.can_initialize_repository; - state.summary = Some(summary); - state.last_error = None; - state.can_initialize_repository = false; - } - Err(error) => { - let is_missing_repository = is_missing_repository(&error); - let error = error.to_string(); - changed = state.summary.is_some() - || state.last_error.as_ref() != Some(&error) - || state.can_initialize_repository != is_missing_repository; - if changed { - diff_refresh = Some(DiffRefreshMode::Full); - } - state.summary = None; - state.last_error = Some(error); - state.can_initialize_repository = is_missing_repository; - } - } - true - }); - - match diff_refresh { - Some(DiffRefreshMode::Full) => super::prewarm_diff(root.to_path_buf(), false, cx), - Some(DiffRefreshMode::Paths(paths)) => { - super::prewarm_diff_paths(root.to_path_buf(), paths, false, cx); - } - None => {} - } - - if changed { - cx.notify(); - } - - should_continue -} - -fn summary_diff_refresh( - previous: Option<&RepositorySummary>, - next: &RepositorySummary, -) -> Option { - let Some(previous) = previous else { - return Some(DiffRefreshMode::Full); - }; - - if previous.work_dir != next.work_dir - || previous.branch != next.branch - || previous.latest_commit != next.latest_commit - { - return Some(DiffRefreshMode::Full); - } - - let previous_files = previous - .files - .iter() - .map(|change| (change.path.as_str(), change)) - .collect::>(); - let next_files = next - .files - .iter() - .map(|change| (change.path.as_str(), change)) - .collect::>(); - let paths = previous_files - .keys() - .chain(next_files.keys()) - .copied() - .collect::>() - .into_iter() - .filter(|path| previous_files.get(path) != next_files.get(path)) - .map(str::to_string) - .collect::>(); - - if !paths.is_empty() { - return Some(if paths.len() <= TARGETED_DIFF_REFRESH_PATH_LIMIT { - DiffRefreshMode::Paths(paths) - } else { - DiffRefreshMode::Full - }); - } - - summary_diff_state_changed(Some(previous), next).then_some(DiffRefreshMode::Full) -} - -fn summary_diff_state_changed( - previous: Option<&RepositorySummary>, - next: &RepositorySummary, -) -> bool { - match previous { - Some(previous) => { - previous.work_dir != next.work_dir - || previous.changes != next.changes - || previous.insertions != next.insertions - || previous.deletions != next.deletions - || previous.files != next.files - } - None => true, - } -} - -fn refresh_summary( - root: PathBuf, - notify_now: bool, - show_loading: bool, - cx: &mut Context, -) { - let generation = cx.update_global::(|state, _| { - if state.root.as_ref() != Some(&root) { - state.summary = None; - state.last_error = None; - state.can_initialize_repository = false; - state.collapsed_change_dirs.clear(); - } - state.root = Some(root.clone()); - if show_loading { - state.loading = true; - } - state.refresh_generation = state.refresh_generation.wrapping_add(1); - state.refresh_generation - }); - - if notify_now { - cx.notify(); - } - - super::prewarm_diff(root.clone(), notify_now, cx); - - let task = cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { RepositorySummary::discover(&root) }) - .await; - let _ = this.update(cx, |_, cx| { - cx.update_global::(|state, _| { - if state.refresh_generation != generation { - return; - } - state.loading = false; - match result { - Ok(summary) => { - state.summary = Some(summary); - state.last_error = None; - state.can_initialize_repository = false; - } - Err(error) => { - state.can_initialize_repository = is_missing_repository(&error); - state.summary = None; - state.last_error = Some(error.to_string()); - } - } - }); - cx.notify(); - }); - }); - - cx.update_global::(|state, _| { - state.refresh_task = Some(task); - }); -} - -fn run_git_action( - root: PathBuf, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - cx: &mut Context, -) { - clear_error(cx); - cx.update_global::(|state, _| state.loading = true); - cx.notify(); - - spawn_git_action( - root, - action, - |root, cx| refresh_summary(root, true, true, cx), - apply_git_action_error, - cx, - ); -} - -fn run_git_action_app( - root: PathBuf, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - cx: &mut App, -) { - clear_error(cx); - cx.update_global::(|state, _| state.loading = true); - cx.refresh_windows(); - - cx.spawn(async move |cx| { - let action_root = root.clone(); - let result = cx - .background_executor() - .spawn(async move { action(action_root) }) - .await; - let _ = cx.update(|cx| match result { - Ok(()) => refresh_summary_app(root, true, true, cx), - Err(error) => apply_git_action_error_app(error, cx), - }); - }) - .detach(); -} - -fn apply_git_action_error_app(error: kosmos_git::Error, cx: &mut App) { - cx.update_global::(|state, _| { - state.loading = false; - state.last_error = Some(error.to_string()); - }); - cx.refresh_windows(); -} - -fn refresh_summary_app(root: PathBuf, notify_now: bool, show_loading: bool, cx: &mut App) { - let generation = cx.update_global::(|state, _| { - if state.root.as_ref() != Some(&root) { - state.summary = None; - state.last_error = None; - state.can_initialize_repository = false; - state.collapsed_change_dirs.clear(); - } - state.root = Some(root.clone()); - if show_loading { - state.loading = true; - } - state.refresh_generation = state.refresh_generation.wrapping_add(1); - state.refresh_generation - }); - - if notify_now { - cx.refresh_windows(); - } - - super::refresh_diff_if_loaded_app(root.clone(), notify_now, cx); - - cx.spawn(async move |cx| { - let result = cx - .background_executor() - .spawn(async move { RepositorySummary::discover(&root) }) - .await; - let _ = cx.update(|cx| { - cx.update_global::(|state, _| { - if state.refresh_generation != generation { - return; - } - state.loading = false; - match result { - Ok(summary) => { - state.summary = Some(summary); - state.last_error = None; - state.can_initialize_repository = false; - } - Err(error) => { - state.can_initialize_repository = is_missing_repository(&error); - state.summary = None; - state.last_error = Some(error.to_string()); - } - } - }); - cx.refresh_windows(); - }); - }) - .detach(); -} - -fn run_git_action_with_toast( - root: PathBuf, - success_title: &'static str, - error_title: &'static str, - action: impl FnOnce(PathBuf) -> Result<(), kosmos_git::Error> + Send + 'static, - cx: &mut Context, -) { - clear_error(cx); - cx.update_global::(|state, _| state.loading = true); - cx.notify(); - - spawn_git_action( - root, - action, - move |root, cx| { - cx.update_global::(|state, _| { - state.sync_action_running = None; - }); - show_git_success(cx, success_title); - refresh_summary(root, true, true, cx); - }, - move |error, cx| { - cx.update_global::(|state, _| { - state.loading = false; - state.sync_action_running = None; - }); - show_git_error(cx, error_title, git_error_message(error)); - cx.notify(); - }, - cx, - ); -} - -fn show_git_success(cx: &mut App, title: impl Into) { - push_git_notification(cx, Notification::success(title.into())); -} - -fn show_git_error( - cx: &mut App, - title: impl Into, - message: impl Into, -) { - push_git_notification(cx, Notification::error(message.into()).title(title.into())); -} - -fn push_git_notification(cx: &mut App, notification: Notification) { - let Some(window_handle) = cx - .active_window() - .or_else(|| cx.windows().into_iter().next()) - else { - return; - }; - - let _ = window_handle.update(cx, move |_, window, cx| { - window.push_notification(notification, cx); - }); -} - -fn run_sync_action( - root: PathBuf, - action: GitSyncAction, - remember: bool, - cx: &mut Context, -) { - cx.update_global::(|state, _| { - if remember { - state.last_sync_action = action; - } - state.sync_action_running = Some(action); - }); - - run_git_action_with_toast( - root, - action.success_title(), - action.error_title(), - move |root| match action { - GitSyncAction::Fetch => kosmos_git::fetch(root), - GitSyncAction::Pull => kosmos_git::pull(root), - GitSyncAction::PullRebase => kosmos_git::pull_rebase(root), - GitSyncAction::Push => kosmos_git::push(root), - GitSyncAction::ForcePush => kosmos_git::force_push(root), - }, - cx, - ); -} - -fn git_error_message(error: kosmos_git::Error) -> String { - match error { - kosmos_git::Error::Status(message) if message.trim().is_empty() => { - "Git command failed".to_string() - } - kosmos_git::Error::Status(message) => message, - error => error.to_string(), - } -} - -fn is_missing_repository(error: &kosmos_git::Error) -> bool { - matches!(error, kosmos_git::Error::Discover { .. }) -} - -fn commit_tracked( - root: PathBuf, - message: String, - input: Entity, - _window: &mut Window, - cx: &mut Context, -) { - let message = message.trim().to_string(); - if message.is_empty() { - cx.update_global::(|state, _| { - state.last_error = Some("Commit message is required".to_string()) - }); - cx.notify(); - return; - } - - clear_error(cx); - cx.update_global::(|state, _| state.loading = true); - cx.notify(); - - spawn_git_action( - root, - move |root| kosmos_git::commit_staged(root, &message), - move |root, cx| { - clear_commit_message_input(input, cx); - refresh_summary(root, true, true, cx); - }, - apply_commit_error, - cx, - ); -} - -fn clear_commit_message_input(input: Entity, cx: &mut App) { - let Some(window_handle) = cx - .active_window() - .or_else(|| cx.windows().into_iter().next()) - else { - return; - }; - - let _ = window_handle.update(cx, move |_, window, cx| { - input.update(cx, |input, cx| input.set_value("", window, cx)); - }); -} - -fn apply_commit_error( - error: kosmos_git::Error, - cx: &mut Context, -) { - let message = git_error_message(error.clone()); - cx.update_global::(|state, _| { - state.loading = false; - state.last_error = Some(error.to_string()); - }); - show_git_error(cx, "Commit failed", message); - cx.refresh_windows(); -} - -fn clear_error(cx: &mut App) { - cx.update_global::(|state, _| state.last_error = None); -} - -fn plural(count: usize) -> &'static str { - if count == 1 { "" } else { "s" } -} diff --git a/crates/ui/src/tabs/git/parts/state_and_header.rs b/crates/ui/src/tabs/git/parts/state_and_header.rs deleted file mode 100644 index 66de134f..00000000 --- a/crates/ui/src/tabs/git/parts/state_and_header.rs +++ /dev/null @@ -1,373 +0,0 @@ -use std::{ - collections::{BTreeSet, HashMap}, - path::{Path, PathBuf}, - rc::Rc, - time::Duration, -}; - -use gpui::{ - Anchor, App, ClickEvent, Entity, IntoElement, MouseButton, Pixels, SharedString, Task, div, - prelude::*, rems, -}; - -use file_tree::ActiveFileTree; -use gpui_component::{ - Disableable, Icon as ComponentIcon, Sizable, Size, WindowExt, - alert::Alert, - button::{Button, ButtonVariants}, - checkbox::Checkbox, - dialog::Dialog, - input::{Input, InputState}, - list::ListItem, - menu::{DropdownMenu, PopupMenuItem}, - notification::Notification, - tag::Tag as ComponentTag, - tree::{TreeEntry, TreeItem, TreeState, tree as component_tree}, -}; -use icons::{Icon, IconName}; -use kosmos_git::{ - Branch, BranchSyncStatus, CommitInfo, FileChange, FileChangeKind, Remote, RepositorySummary, - Stash, Tag, -}; -use tabs::registry; -use theme::ActiveTheme; - -type PopupMenuHandler = Rc; - -fn component_icon(icon: IconName) -> ComponentIcon { - ComponentIcon::empty().path(icon.path()) -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum GitModal { - Branches, - CreateBranch, - Remotes, - CreateRemote, - Stashes, - Tags, - CreateTag, - ConfirmDiscardSelected, - ConfirmDiscard, - ConfirmResolveConflicts, -} - -#[derive(Clone, Copy, Default, PartialEq, Eq)] -enum GitSyncAction { - #[default] - Fetch, - Pull, - PullRebase, - Push, - ForcePush, -} - -impl GitSyncAction { - const ALL: [Self; 5] = [ - Self::Fetch, - Self::Pull, - Self::PullRebase, - Self::Push, - Self::ForcePush, - ]; - - fn label(self) -> &'static str { - match self { - Self::Fetch => "Fetch", - Self::Pull => "Pull", - Self::PullRebase => "Pull (Rebase)", - Self::Push => "Push", - Self::ForcePush => "Push (Force)", - } - } - - fn icon(self) -> IconName { - match self { - Self::Fetch => IconName::Refresh, - Self::Pull | Self::PullRebase => IconName::ArrowDown, - Self::Push | Self::ForcePush => IconName::ArrowUp, - } - } - - fn success_title(self) -> &'static str { - match self { - Self::Fetch => "Fetch completed", - Self::Pull => "Pull completed", - Self::PullRebase => "Pull with rebase completed", - Self::Push => "Push completed", - Self::ForcePush => "Force push completed", - } - } - - fn error_title(self) -> &'static str { - match self { - Self::Fetch => "Fetch failed", - Self::Pull => "Pull failed", - Self::PullRebase => "Pull with rebase failed", - Self::Push => "Push failed", - Self::ForcePush => "Force push failed", - } - } - - fn is_danger(self) -> bool { - matches!(self, Self::ForcePush) - } -} - -#[derive(Default)] -struct GitUiState { - root: Option, - summary: Option, - can_initialize_repository: bool, - loading: bool, - refresh_generation: u64, - refresh_task: Option>, - change_tree_state: Option>, - watch_generation: u64, - watch_task: Option>, - modal: Option, - last_error: Option, - pending_conflict_paths: Vec, - pending_conflict_resolution_stages_all: bool, - last_sync_action: GitSyncAction, - sync_action_running: Option, - remotes: Vec, - stashes: Vec, - expanded_stashes: std::collections::HashSet, - collapsed_change_dirs: std::collections::HashSet, - tags: Vec, - branches: Vec, - commit_message: Option>, - branch_search: Option>, - branch_name: Option>, - tag_search: Option>, - remote_search: Option>, - remote_name: Option>, - remote_url: Option>, - tag_name: Option>, - tag_message: Option>, - tag_sha: Option>, -} - -impl Global for GitUiState {} - -const CHANGE_ROW_HEIGHT_REM: f32 = 1.625; -const CHANGE_ROW_PADDING_REM: f32 = 1.00; -const CHANGE_INDENT_REM: f32 = 1.25; -const CHANGE_GUIDE_OFFSET_REM: f32 = 0.625; -const CHANGE_GUIDE_WIDTH_REM: f32 = 0.0625; -const CHANGE_ICON_WIDTH_REM: f32 = 1.25; -const CHANGE_LABEL_PADDING_REM: f32 = 0.25; -const COMMIT_PANEL_HEIGHT_REM: f32 = 13.25; -const COMMIT_MESSAGE_HEIGHT_REM: f32 = 8.25; -const COMMIT_MESSAGE_PADDING_X_REM: f32 = 1.25; -const COMMIT_MESSAGE_PADDING_TOP_REM: f32 = 1.25; -const COMMIT_MESSAGE_PADDING_BOTTOM_REM: f32 = 0.5; -const COMMIT_CONTROLS_INSET_X_REM: f32 = 1.0; -const SYNC_PANEL_INSET_X_REM: f32 = 0.5; -const COMMIT_CONTROLS_INSET_BOTTOM_REM: f32 = 1.0; - -pub fn render_git( - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - ensure_state(window, cx); - let theme = *cx.theme(); - let Some(root) = cx - .file_tree() - .and_then(|tree| tree.read(cx).root().map(|path| path.to_path_buf())) - else { - return empty_state("No workspace open", cx); - }; - - ensure_summary(&root, cx); - let (summary, can_initialize_repository, loading) = { - let state = cx.global::(); - ( - state.summary.clone(), - state.can_initialize_repository, - state.loading, - ) - }; - - div() - .relative() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .bg(theme.bg_surface) - .text_color(theme.text) - .child(header(&root, summary.as_ref(), loading, cx)) - .child( - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .when(summary.is_none(), |this| { - this.child(if loading { - loading_state(cx) - } else if can_initialize_repository { - init_repository_panel(&root, cx) - } else { - empty_panel("Git status unavailable", cx) - }) - }) - .when_some(summary.as_ref(), |this, summary| { - this.child(change_list(&root, summary, cx)) - }), - ) - .when(!can_initialize_repository, |this| { - this.child(commit_panel(&root, summary.as_ref(), cx)) - }) - .when_some( - summary - .as_ref() - .and_then(|summary| summary.latest_commit.clone()), - |this, commit| this.child(latest_commit_panel(commit, cx)), - ) - .into_any_element() -} - -pub fn render_modal_overlay( - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let has_modal = cx - .try_global::() - .is_some_and(|state| state.root.is_some() && state.modal.is_some()); - if has_modal && !window.has_active_dialog(cx) { - window.defer(cx, |window, cx| { - let has_modal = cx - .try_global::() - .is_some_and(|state| state.root.is_some() && state.modal.is_some()); - if has_modal && !window.has_active_dialog(cx) { - window.open_dialog(cx, |dialog, window, cx| { - render_git_modal(dialog, window, cx) - }); - } - }); - } - - div().into_any_element() -} - -fn header( - root: &Path, - summary: Option<&RepositorySummary>, - loading: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let root_refresh = root.to_path_buf(); - let root_stage = root.to_path_buf(); - let root_unstage = root.to_path_buf(); - let root_stash = root.to_path_buf(); - - div() - .flex_none() - .flex() - .items_center() - .justify_between() - .gap_2() - .border_b_1() - .border_color(theme.border_subtle) - .px_3() - .py_2() - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_1p5() - .child(icon_button::( - "git-refresh", - IconName::Refresh, - None, - move |_, _, cx| { - clear_error(cx); - refresh_summary(root_refresh.clone(), true, true, cx); - }, - cx, - )) - .child(change_count(summary, loading, cx)) - .when_some(summary, |this, summary| this.child(diff_stats(summary, cx))), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_1() - .child(icon_button::( - "git-stage-all", - IconName::Add, - Some("Stage All Changes"), - move |_, _, cx| { - stage_all_changes(root_stage.clone(), cx); - }, - cx, - )) - .child(icon_button::( - "git-unstage-all", - IconName::Remove, - Some("Unstage All Changes"), - move |_, _, cx| { - run_git_action(root_unstage.clone(), kosmos_git::unstage_all, cx); - }, - cx, - )) - .child(icon_button::( - "git-stash-staged", - IconName::Archive, - Some("Stash Staged Changes"), - move |_, _, cx| { - run_git_action(root_stash.clone(), kosmos_git::stash_staged, cx); - }, - cx, - )) - .child(more_button(root, cx)), - ) - .into_any_element() -} - -fn change_count( - summary: Option<&RepositorySummary>, - loading: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let label = match summary { - Some(summary) if conflict_count(summary) > 0 => { - let conflicts = conflict_count(summary); - if summary.changes == conflicts { - format!("{conflicts} Conflict{}", plural(conflicts)) - } else { - format!( - "{} Change{}, {conflicts} Conflict{}", - summary.changes, - plural(summary.changes), - plural(conflicts) - ) - } - } - Some(summary) if summary.changes == 0 => "No Changes".to_string(), - Some(summary) => format!("{} Change{}", summary.changes, plural(summary.changes)), - None if loading => "Loading Changes".to_string(), - None => "No Changes".to_string(), - }; - div() - .text_xs() - .text_color(theme.text_emphasis) - .child(label) - .into_any_element() -} - -fn conflict_count(summary: &RepositorySummary) -> usize { - summary - .files - .iter() - .filter(|file| file.kind == FileChangeKind::Conflicted) - .count() -} diff --git a/crates/ui/src/tabs/git/parts/tags_and_stashes.rs b/crates/ui/src/tabs/git/parts/tags_and_stashes.rs deleted file mode 100644 index 51170754..00000000 --- a/crates/ui/src/tabs/git/parts/tags_and_stashes.rs +++ /dev/null @@ -1,396 +0,0 @@ -fn tags_modal_body(root: &Path, cx: &mut App) -> AnyElement { - let (tag_search, tags) = { - let state = cx.global::(); - ( - state.tag_search.as_ref().unwrap().clone(), - state.tags.clone(), - ) - }; - let theme = *cx.theme(); - let query = tag_search.read(cx).value().trim().to_lowercase(); - let has_tags = !tags.is_empty(); - let tags = if query.is_empty() { - tags - } else { - tags - .into_iter() - .filter(|tag| { - tag.name.to_lowercase().contains(&query) - || tag.message.to_lowercase().contains(&query) - }) - .collect() - }; - - div() - .flex() - .flex_col() - .gap_2() - .child(tag_search_row(root.to_path_buf(), tag_search, theme, cx)) - .child( - div() - .flex() - .flex_col() - .gap_1() - .when(tags.is_empty(), |this| { - this.child( - div() - .rounded(rems(0.375)) - .p_3() - .text_sm() - .text_color(theme.text_subtle) - .child(if has_tags { - "No tags match your search" - } else { - "No tags" - }), - ) - }) - .when(!tags.is_empty(), |this| { - this.children(tags.into_iter().map(|tag| tag_row(root.to_path_buf(), tag, cx))) - }), - ) - .into_any_element() -} - -fn tag_search_row( - root: PathBuf, - input: Entity, - theme: theme::Theme, - cx: &mut App, -) -> AnyElement { - let (tag_name, tag_message, tag_sha) = { - let state = cx.global::(); - ( - state.tag_name.as_ref().unwrap().clone(), - state.tag_message.as_ref().unwrap().clone(), - state.tag_sha.as_ref().unwrap().clone(), - ) - }; - - div() - .flex() - .items_center() - .gap_2() - .mt(rems(0.25)) - .child(div().flex_1().min_w_0().child(search_input(input, theme))) - .child( - Button::new("git-new-tag") - .primary() - .label("New") - .on_click(move |_, window, cx| { - tag_name.update(cx, |input, cx| input.set_value("", window, cx)); - tag_message.update(cx, |input, cx| input.set_value("", window, cx)); - tag_sha.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root.clone(), GitModal::CreateTag, cx); - }), - ) - .into_any_element() -} - -fn create_tag_modal_body(cx: &mut App) -> AnyElement { - let (name, message, sha) = { - let state = cx.global::(); - ( - state.tag_name.as_ref().unwrap().clone(), - state.tag_message.as_ref().unwrap().clone(), - state.tag_sha.as_ref().unwrap().clone(), - ) - }; - - div() - .flex() - .flex_col() - .gap_3() - .child(input_row("Tag Name", name)) - .child(input_row("Tag Message (optional)", message)) - .child(input_row("Commit SHA (optional)", sha)) - .into_any_element() -} - -fn create_tag_modal_footer(root: &Path, cx: &mut App) -> AnyElement { - let (name, message, sha) = { - let state = cx.global::(); - ( - state.tag_name.as_ref().unwrap().clone(), - state.tag_message.as_ref().unwrap().clone(), - state.tag_sha.as_ref().unwrap().clone(), - ) - }; - let root_cancel = root.to_path_buf(); - let root_create = root.to_path_buf(); - let cancel_name = name.clone(); - let cancel_message = message.clone(); - let cancel_sha = sha.clone(); - let create_name = name.clone(); - let create_message = message.clone(); - let create_sha = sha.clone(); - - div() - .flex() - .justify_end() - .gap_2() - .child(action_button( - "git-cancel-create-tag", - "Cancel", - false, - move |_, window, cx| { - cancel_name.update(cx, |input, cx| input.set_value("", window, cx)); - cancel_message.update(cx, |input, cx| input.set_value("", window, cx)); - cancel_sha.update(cx, |input, cx| input.set_value("", window, cx)); - open_modal_app(root_cancel.clone(), GitModal::Tags, cx); - }, - cx, - )) - .child( - Button::new("git-confirm-create-tag") - .primary() - .label("Create") - .on_click(move |_, _, cx| { - let name_value = create_name.read(cx).value().trim().to_string(); - if name_value.is_empty() { - return; - } - let message_value = create_message.read(cx).value().trim().to_string(); - let sha_value = create_sha.read(cx).value().trim().to_string(); - run_modal_action_after_success_app( - root_create.clone(), - GitModal::Tags, - move |root| { - kosmos_git::add_tag( - root, - &name_value, - Some(message_value.as_str()), - Some(sha_value.as_str()), - ) - }, - move |cx| { - cx.update_global::(|state, _| { - state.modal = Some(GitModal::Tags) - }); - }, - cx, - ); - }), - ) - .into_any_element() -} - -fn stashes_modal_body(root: &Path, cx: &mut App) -> AnyElement { - let (stashes, expanded) = { - let state = cx.global::(); - (state.stashes.clone(), state.expanded_stashes.clone()) - }; - let theme = *cx.theme(); - - let mut body = div().flex().flex_col().gap_2(); - if stashes.is_empty() { - body = body.child( - div() - .rounded(rems(0.375)) - .p_3() - .text_sm() - .text_color(theme.text_subtle) - .child("No stashes"), - ); - } else { - body = body.children( - stashes - .into_iter() - .map(|stash| stash_row(root.to_path_buf(), stash, expanded.clone(), cx)), - ); - } - body.into_any_element() -} - -fn stash_row( - root: PathBuf, - stash: Stash, - expanded: std::collections::HashSet, - cx: &mut App, -) -> AnyElement { - let theme = *cx.theme(); - let is_expanded = expanded.contains(&stash.id); - let toggle_id = stash.id.clone(); - let apply_id = stash.id.clone(); - let delete_id = stash.id.clone(); - let root_apply = root.clone(); - let root_delete = root.clone(); - - div() - .flex() - .flex_col() - .gap_2() - .rounded(rems(0.375)) - .p_2() - .hover(move |this| this.bg(theme.bg_hover)) - .child( - div() - .flex() - .items_center() - .justify_between() - .gap_2() - .child( - div() - .min_w_0() - .flex() - .items_start() - .gap_2() - .on_mouse_down( - MouseButton::Left, - move |_, _, cx| { - toggle_stash(&toggle_id, cx); - }, - ) - .child( - div().mt(rems(0.1875)).child( - Icon::new(if is_expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }) - .size(14.0) - .color(theme.text_muted), - ), - ) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_0p5() - .child( - div() - .text_sm() - .text_color(theme.text) - .child(stash.id.clone()), - ) - .child( - div() - .text_xs() - .text_color(theme.text_subtle) - .child(stash.message.clone()), - ), - ), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_1() - .child(icon_action_button( - SharedString::from(format!("git-apply-stash:{}", stash.id)), - IconName::ArrowDown, - theme.text_muted, - move |_, _, cx| { - run_modal_action_app( - root_apply.clone(), - GitModal::Stashes, - { - let apply_id = apply_id.clone(); - move |root| kosmos_git::apply_stash(root, &apply_id) - }, - cx, - ); - }, - cx, - )) - .child(delete_button( - SharedString::from(format!("git-delete-stash:{}", stash.id)), - move |_, _, cx| { - run_modal_action_app( - root_delete.clone(), - GitModal::Stashes, - { - let delete_id = delete_id.clone(); - move |root| kosmos_git::delete_stash(root, &delete_id) - }, - cx, - ); - }, - cx, - )), - ), - ) - .when(is_expanded, |this| { - this.child( - div().ml_6().flex().flex_col().gap_1().children( - stash - .files - .into_iter() - .map(|file| stash_file_row(file, theme)), - ), - ) - }) - .into_any_element() -} - -fn stash_file_row(file: kosmos_git::StashFile, theme: theme::Theme) -> AnyElement { - div() - .flex() - .items_center() - .gap_2() - .min_w_0() - .text_xs() - .child( - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .text_color(theme.text_subtle) - .child(file.path), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_1() - .child(diff_stat_text(format!("+{}", file.insertions), theme.success)) - .child(diff_stat_text(format!("-{}", file.deletions), theme.danger)), - ) - .into_any_element() -} - -fn input_row(label: &'static str, input: Entity) -> AnyElement { - div() - .flex() - .flex_col() - .gap_1() - .child(div().text_xs().child(label)) - .child(Input::new(&input).bordered(false)) - .into_any_element() -} - -fn search_input(input: Entity, theme: theme::Theme) -> AnyElement { - Input::new(&input) - .bordered(false) - .cleanable(true) - .prefix( - component_icon(IconName::Search) - .small() - .text_color(gpui::Hsla::from(theme.text_muted)), - ) - .w_full() - .into_any_element() -} - -fn remote_row(root: PathBuf, remote: Remote, cx: &mut App) -> AnyElement { - let name = remote.name.clone(); - list_row( - remote.name, - remote.url, - true, - move |_, _, cx| { - let name = name.clone(); - run_modal_action_app( - root.clone(), - GitModal::Remotes, - move |root| kosmos_git::delete_remote(root, &name), - cx, - ); - }, - cx, - ) -} diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs deleted file mode 100644 index 057479a2..00000000 --- a/crates/ui/src/tabs/mod.rs +++ /dev/null @@ -1,144 +0,0 @@ -mod blank; -mod diff; -mod file_editor; -pub mod file_search; -pub mod file_tree; -pub mod git; -pub mod settings; -pub mod terminal; - -use std::path::Path; - -use gpui::{AnyElement, App, Context, IntoElement, Window, div}; -use icons::IconName; - -use tabs::{Tab, registry}; - -use crate::delegate::{PaneDelegate, SettingsDelegate}; - -pub fn render( - workspace_id: usize, - workspace_path: &Path, - pane_id: usize, - tab: &Tab, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - match tab.kind.as_str() { - "blank" => blank::render(pane_id, tab.id, cx), - "terminal" => terminal::render( - workspace_id, - terminal_cwd_for_tab(workspace_path, tab), - tab.id, - window, - cx, - ), - "file_tree" => file_tree::render(window, cx), - "file_search" => file_search::render(workspace_path, window, cx), - "git" => git::render(window, cx), - "diff" => diff::render(workspace_path, tab, window, cx), - "settings" => settings::render(window, cx), - "file_editor" => file_editor::render(tab, window, cx), - _ => div().into_any_element(), - } -} - -pub fn install_keybindings(cx: &mut App) { - file_editor::install_default_keybindings(cx); - terminal::install_default_keybindings(cx); -} - -pub fn drop_file_editor_tab(tab_id: usize, cx: &mut App) { - file_editor::drop_tab(tab_id, cx); -} - -pub fn request_file_editor_reveal( - path: std::path::PathBuf, - line: usize, - column: usize, - cx: &mut App, -) { - file_editor::request_reveal(path, line, column, cx); -} - -pub fn request_diff_focus(root: std::path::PathBuf, file_path: String, cx: &mut App) { - diff::request_focus(root, file_path, cx); -} - -pub fn refresh_diff_if_loaded( - root: std::path::PathBuf, - notify_now: bool, - cx: &mut Context, -) { - diff::refresh_if_loaded(root, notify_now, cx); -} - -pub fn refresh_diff_paths_if_loaded( - root: std::path::PathBuf, - paths: Vec, - notify_now: bool, - cx: &mut Context, -) { - diff::refresh_paths_if_loaded(root, paths, notify_now, cx); -} - -pub fn prewarm_diff( - root: std::path::PathBuf, - notify_now: bool, - cx: &mut Context, -) { - diff::prewarm(root, notify_now, cx); -} - -pub fn prewarm_diff_paths( - root: std::path::PathBuf, - paths: Vec, - notify_now: bool, - cx: &mut Context, -) { - diff::prewarm_paths(root, paths, notify_now, cx); -} - -pub fn refresh_diff_if_loaded_app(root: std::path::PathBuf, notify_now: bool, cx: &mut App) { - diff::refresh_if_loaded_app(root, notify_now, cx); -} - -pub fn icon_for_tab(tab: &Tab) -> IconName { - if tab.kind.as_str() == registry::FILE_EDITOR.id - && let Some(path) = &tab.path - && let Some(icon) = icon_for_path(path) - { - return icon; - } - icon_for_kind(&tab.kind) -} - -pub fn icon_for_kind(kind_id: &str) -> IconName { - match kind_id { - id if id == registry::BLANK.id => IconName::EmptyWindow, - id if id == registry::FILE_TREE.id => IconName::ListTree, - id if id == registry::FILE_SEARCH.id => IconName::Search, - id if id == registry::GIT.id => IconName::SourceControl, - id if id == registry::DIFF.id => IconName::Diff, - id if id == registry::TERMINAL.id => IconName::Terminal, - id if id == registry::SETTINGS.id => IconName::SettingsGear, - id if id == registry::FILE_EDITOR.id => IconName::File, - _ => IconName::File, - } -} - -fn terminal_cwd_for_tab<'a>(workspace_path: &'a Path, tab: &'a Tab) -> &'a Path { - tab.path - .as_deref() - .filter(|path| path.is_absolute() && path.is_dir()) - .unwrap_or(workspace_path) -} - -fn icon_for_path(path: &Path) -> Option { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && let Some(icon) = IconName::for_file_name(name) - { - return Some(icon); - } - language::from_path(path).and_then(|id| IconName::for_language(id.as_str())) -} diff --git a/crates/ui/src/tabs/settings/cards.rs b/crates/ui/src/tabs/settings/cards.rs deleted file mode 100644 index d3301775..00000000 --- a/crates/ui/src/tabs/settings/cards.rs +++ /dev/null @@ -1,187 +0,0 @@ -use gpui::{AnyElement, Context, IntoElement, SharedString, div, prelude::*, rems}; -use gpui_component::{ - Disableable, Sizable, - button::{Button, ButtonVariants}, - label::Label, -}; - -use registry::{RegistryEntry, ToolKind}; -use theme::ActiveTheme; - -use crate::delegate::{ActiveSettingsUi, SettingsDelegate}; - -pub fn render_marketplace( - kind: ToolKind, - query: &str, - include_all: bool, - cx: &mut Context, -) -> AnyElement { - let cards: Vec = registry::by_kind(kind) - .filter(|entry| include_all || tool_matches(entry, query)) - .map(|entry| render_tool_card(entry, cx)) - .collect(); - div() - .flex() - .flex_col() - .gap_3() - .min_w_0() - .children(cards) - .into_any_element() -} - -pub fn has_match(kind: ToolKind, query: &str) -> bool { - registry::by_kind(kind).any(|entry| tool_matches(entry, query)) -} - -fn render_tool_card( - entry: &'static RegistryEntry, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let installed = installer::is_installed(entry); - let installing = cx.settings_ui().installing.contains(entry.id); - let error = cx.settings_ui().install_errors.get(entry.id).cloned(); - - let names: Vec<&'static str> = entry - .languages - .iter() - .filter_map(|id| language::info(id).map(|i| i.name)) - .collect(); - - let kinds: SharedString = entry - .kinds - .iter() - .map(|k| match k { - ToolKind::Lsp => "LSP", - ToolKind::Formatter => "Formatter", - ToolKind::Linter => "Linter", - }) - .collect::>() - .join(" · ") - .into(); - - let action = install_action(entry.id, installed, installing, cx); - - let header = div() - .flex() - .flex_row() - .items_center() - .justify_between() - .gap_2() - .child( - div() - .flex() - .flex_col() - .gap_1() - .child( - Label::new(entry.id) - .text_lg() - .text_color(theme.text_emphasis), - ) - .child(Label::new(kinds).text_xs().text_color(theme.text_subtle)), - ) - .child(action); - - let supports_row = div() - .flex() - .flex_row() - .flex_wrap() - .items_center() - .gap_x_2() - .gap_y_1() - .min_w_0() - .child( - Label::new("Supports:") - .text_xs() - .text_color(theme.text_subtle), - ) - .children(names.into_iter().map(|name| { - div() - .px_2() - .rounded(rems(0.25)) - .bg(theme.bg_elevated) - .text_xs() - .text_color(theme.text_muted) - .child(name) - })); - - let mut card = div() - .flex() - .flex_col() - .gap_2() - .p_3() - .rounded(rems(0.5)) - .bg(theme.bg_surface) - .border_1() - .border_color(theme.border_subtle) - .min_w_0() - .w_full() - .overflow_hidden() - .child(header) - .child(supports_row); - - if let Some(err) = error { - card = card.child(div().text_xs().text_color(theme.danger).child(err)); - } - - card.into_any_element() -} - -fn tool_matches(entry: &RegistryEntry, query: &str) -> bool { - query.is_empty() - || text_matches(entry.id, query) - || entry.languages.iter().any(|language_id| { - text_matches(language_id, query) - || language::info(language_id).is_some_and(|info| text_matches(info.name, query)) - }) - || entry - .kinds - .iter() - .any(|kind| text_matches(tool_kind_label(*kind), query)) -} - -fn tool_kind_label(kind: ToolKind) -> &'static str { - match kind { - ToolKind::Lsp => "LSP", - ToolKind::Formatter => "Formatter", - ToolKind::Linter => "Linter", - } -} - -fn text_matches(text: &str, query: &str) -> bool { - text.to_lowercase().contains(query) -} - -fn install_action( - tool_id: &'static str, - installed: bool, - installing: bool, - cx: &mut Context, -) -> AnyElement { - if installing { - return Button::new(SharedString::from(format!("install-{tool_id}"))) - .secondary() - .small() - .disabled(true) - .label("Installing…") - .into_any_element(); - } - - let entry = registry::get(tool_id).expect("tool id must exist in registry"); - let label = if installed { "Remove" } else { "Install" }; - - Button::new(SharedString::from(format!("install-{tool_id}"))) - .secondary() - .small() - .when(installed, |this| this.danger()) - .label(label) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - if installed { - this.uninstall_tool(entry, cx); - } else { - this.install_tool(entry, cx); - } - })) - .into_any_element() -} diff --git a/crates/ui/src/tabs/settings/content.rs b/crates/ui/src/tabs/settings/content.rs deleted file mode 100644 index a59a846f..00000000 --- a/crates/ui/src/tabs/settings/content.rs +++ /dev/null @@ -1,295 +0,0 @@ -use gpui::{AnyElement, Context, Entity, IntoElement, SharedString, Window, div, prelude::*, rems}; -use gpui_component::{ - Icon as ComponentIcon, IconName as ComponentIconName, Sizable, Size, - alert::Alert, - group_box::{GroupBox, GroupBoxVariants}, - input::{Input, InputState}, - label::Label, - scroll::ScrollableElement, -}; - -use registry::ToolKind; -use settings::{ActiveSettings, Category, Setting, Settings}; -use theme::ActiveTheme; - -use crate::delegate::SettingsDelegate; -use crate::tabs::settings::{cards, controls}; - -pub fn render( - search: Option>, - open_dropdown: Option<&'static str>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let query = search - .as_ref() - .map(|input| input.read(cx).value().trim().to_lowercase()) - .unwrap_or_default(); - let sections: Vec = Settings::categories() - .iter() - .filter_map(|category| render_category(category, &query, open_dropdown, window, cx)) - .collect(); - - let content = if sections.is_empty() { - vec![render_empty_state(&query)] - } else { - sections - }; - - div() - .id("settings-content") - .flex_1() - .min_w_0() - .min_h_0() - .h_full() - .flex() - .flex_col() - .gap_4() - .child( - div() - .flex() - .flex_col() - .gap_3() - .p(rems(1.5)) - .pb_4() - .border_b_1() - .border_color(theme.border_subtle) - .child(render_search(search, theme)), - ) - .child( - div().flex_1().min_h_0().child( - div() - .size_full() - .child( - div() - .px(rems(1.5)) - .pb(rems(1.5)) - .flex() - .flex_col() - .gap_4() - .children(content), - ) - .overflow_y_scrollbar(), - ), - ) - .into_any_element() -} - -fn render_search(search: Option>, theme: theme::Theme) -> AnyElement { - match search { - Some(search) => div() - .w_full() - .min_w_0() - .max_w(rems(28.0)) - .child( - Input::new(&search) - .bordered(false) - .prefix( - ComponentIcon::new(ComponentIconName::Search) - .small() - .text_color(gpui::Hsla::from(theme.text_muted)), - ) - .cleanable(true) - .w_full(), - ) - .into_any_element(), - None => div().into_any_element(), - } -} - -fn render_category( - category: &'static Category, - query: &str, - open_dropdown: Option<&'static str>, - window: &mut Window, - cx: &mut Context, -) -> Option { - let theme = *cx.theme(); - let category_match = - query.is_empty() || text_matches(category.id, query) || text_matches(category.name, query); - let body = match marketplace_kind(category) { - Some(kind) => { - if !category_match && !cards::has_match(kind, query) { - return None; - } - cards::render_marketplace(kind, query, category_match, cx) - } - None => { - if !category_match - && !category - .settings - .iter() - .any(|setting| setting_matches(setting, query)) - { - return None; - } - render_rows(category, query, category_match, open_dropdown, window, cx) - } - }; - - Some( - GroupBox::new() - .id(SharedString::from(format!( - "settings-category:{}", - category.id - ))) - .outline() - .title(render_category_title(category, query, theme)) - .child(body) - .into_any_element(), - ) -} - -fn marketplace_kind(category: &Category) -> Option { - match category.id { - "language_servers" => Some(ToolKind::Lsp), - "formatters" => Some(ToolKind::Formatter), - "linters" => Some(ToolKind::Linter), - _ => None, - } -} - -fn render_category_title( - category: &'static Category, - query: &str, - theme: theme::Theme, -) -> AnyElement { - div() - .flex() - .items_center() - .gap_2() - .child(ComponentIcon::empty().path(category.icon.path()).small()) - .child( - Label::new(category.name) - .text_lg() - .text_color(theme.text_emphasis) - .highlights(query.to_string()), - ) - .into_any_element() -} - -fn render_rows( - category: &'static Category, - query: &str, - include_all: bool, - open_dropdown: Option<&'static str>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let mut rows: Vec = Vec::new(); - let mut current_group: Option<&'static str> = None; - - for setting in category - .settings - .iter() - .filter(|setting| include_all || setting_matches(setting, query)) - { - if setting.group != current_group { - current_group = setting.group; - if let Some(group) = setting.group { - rows.push(render_group_header(group, query, theme)); - } - } - rows.push(render_row(setting, query, open_dropdown, window, cx)); - } - - div() - .flex() - .flex_col() - .gap_4() - .children(rows) - .into_any_element() -} - -fn render_group_header(name: &'static str, query: &str, theme: theme::Theme) -> AnyElement { - div() - .pt_4() - .pb_1() - .child( - Label::new(name) - .text_lg() - .text_color(theme.text_emphasis) - .highlights(query.to_string()), - ) - .into_any_element() -} - -fn render_row( - setting: &'static Setting, - query: &str, - open_dropdown: Option<&'static str>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let value = cx.settings().value(setting); - let description = setting.description.map(|desc| { - Label::new(desc) - .text_sm() - .text_color(theme.text_subtle) - .highlights(query.to_string()) - .into_any_element() - }); - - div() - .flex() - .flex_row() - .items_center() - .justify_between() - .gap_4() - .py_2() - .border_b_1() - .border_color(theme.border_subtle) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - Label::new(setting.name) - .text_sm() - .text_color(theme.text) - .highlights(query.to_string()), - ) - .children(description), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .child(controls::render(setting, &value, open_dropdown, window, cx)), - ) - .into_any_element() -} - -fn render_empty_state(query: &str) -> AnyElement { - let message = if query.is_empty() { - "No settings available." - } else { - "No settings match your search." - }; - Alert::info("settings-no-results", message) - .with_size(Size::Small) - .into_any_element() -} - -fn setting_matches(setting: &Setting, query: &str) -> bool { - query.is_empty() - || text_matches(setting.id, query) - || text_matches(setting.name, query) - || setting - .description - .is_some_and(|description| text_matches(description, query)) - || setting - .group - .is_some_and(|group| text_matches(group, query)) -} - -fn text_matches(text: &str, query: &str) -> bool { - text.to_lowercase().contains(query) -} diff --git a/crates/ui/src/tabs/settings/controls.rs b/crates/ui/src/tabs/settings/controls.rs deleted file mode 100644 index a086e48e..00000000 --- a/crates/ui/src/tabs/settings/controls.rs +++ /dev/null @@ -1,525 +0,0 @@ -use std::collections::HashSet; - -use gpui::{ - Anchor, AnyElement, App, Context, ElementId, Entity, IntoElement, MouseButton, SharedString, - Subscription, Window, div, prelude::*, rems, -}; -use gpui_component::{ - button::{Button, ButtonVariants}, - input::{Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction}, - menu::{DropdownMenu, PopupMenuItem}, - popover::Popover, - switch::Switch, -}; - -use settings::{DropdownOption, Setting, SettingControl, SettingValue, Settings}; -use theme::ActiveTheme; - -use crate::delegate::SettingsDelegate; -use crate::tabs::settings::state::ActiveSettingsInputs; - -const SETTING_DROPDOWN_WIDTH_REM: f32 = 11.25; -const SETTING_MULTI_SELECT_WIDTH_REM: f32 = 13.75; -const SETTING_NUMBER_WIDTH_REM: f32 = 7.5; - -struct NumberSettingState { - input: Entity, - committed_value: i64, - _subscriptions: Vec, -} - -pub fn render( - setting: &'static Setting, - value: &SettingValue, - open_dropdown: Option<&'static str>, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let setting_id = setting.id; - match &setting.control { - SettingControl::Switch { .. } => Switch::new(make_id("setting-switch", setting_id)) - .checked(value.as_bool().unwrap_or(false)) - .on_click(cx.listener(move |this, new_value: &bool, _, cx| { - this.set_setting_value(setting_id, SettingValue::Bool(*new_value), cx); - })) - .into_any_element(), - - SettingControl::Number { min, max, step, .. } => { - let input = number_setting_input( - setting_id, - value.as_int().unwrap_or(0), - *min, - *max, - *step, - window, - cx, - ); - NumberInput::new(&input) - .w(rems(SETTING_NUMBER_WIDTH_REM)) - .into_any_element() - } - - SettingControl::Dropdown { options, .. } => { - let current = SharedString::from(value.as_str().unwrap_or("")); - let delegate = cx.entity().clone(); - let label = options - .iter() - .find(|option| option.id == current.as_ref()) - .map(|option| option.label) - .unwrap_or(current.as_ref()); - - Button::new(make_id("setting-dropdown", setting_id)) - .secondary() - .label(label) - .dropdown_caret(true) - .w(rems(SETTING_DROPDOWN_WIDTH_REM)) - .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, window, _| { - let menu_width = rems(SETTING_DROPDOWN_WIDTH_REM).to_pixels(window.rem_size()); - options - .iter() - .fold(menu.min_w(menu_width).max_w(menu_width), |menu, option| { - let checked = option.id == current.as_ref(); - let delegate = delegate.clone(); - menu.item(PopupMenuItem::new(option.label).checked(checked).on_click( - move |_, _, cx| { - delegate.update(cx, |this, cx| { - this.set_setting_value( - setting_id, - SettingValue::String(option.id.into()), - cx, - ); - }); - }, - )) - }) - }) - .into_any_element() - } - - SettingControl::Input { placeholder, .. } => { - let theme = *cx.theme(); - match cx.settings_inputs().get(setting_id) { - Some(entity) => div() - .min_w(rems(13.75)) - .child(Input::new(&entity)) - .into_any_element(), - None => div() - .h(rems(1.75)) - .min_w(rems(13.75)) - .px_2() - .flex() - .items_center() - .rounded(rems(0.3125)) - .bg(theme.bg_elevated) - .border_1() - .border_color(theme.border) - .text_sm() - .text_color(theme.text_subtle) - .child(placeholder.unwrap_or("")) - .into_any_element(), - } - } - - SettingControl::MultiSelect { - options, ordered, .. - } => { - let current: Vec = value - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| match v { - SettingValue::String(s) => Some(s.clone()), - _ => None, - }) - .collect() - }) - .unwrap_or_default(); - render_multi_select( - setting_id, - current, - options(), - *ordered, - open_dropdown == Some(setting_id), - cx, - ) - } - } -} - -fn make_id(prefix: &str, id: &str) -> SharedString { - SharedString::from(format!("{prefix}:{id}")) -} - -fn render_multi_select( - setting_id: &'static str, - selected: Vec, - options: &'static [DropdownOption], - ordered: bool, - is_open: bool, - cx: &mut Context, -) -> AnyElement { - let summary = multi_select_summary(&selected, options); - let on_open_change = cx.listener(move |this, _: &bool, _, cx| { - this.toggle_settings_dropdown(setting_id, cx); - }); - let trigger = Button::new(make_id("setting-multi-select", setting_id)) - .secondary() - .w(rems(SETTING_MULTI_SELECT_WIDTH_REM)) - .label(summary) - .dropdown_caret(true); - - Popover::new(make_id("setting-multi-select-popover", setting_id)) - .anchor(Anchor::TopLeft) - .appearance(false) - .open(is_open) - .trigger(trigger) - .on_open_change(move |is_open, window, cx| on_open_change(is_open, window, cx)) - .content(move |_, _, cx| { - render_multi_select_menu(setting_id, selected.clone(), options, ordered, cx) - }) - .into_any_element() -} - -fn multi_select_summary(selected: &[SharedString], options: &[DropdownOption]) -> SharedString { - if selected.is_empty() { - return "None".into(); - } - - selected - .iter() - .map(|id| { - options - .iter() - .find(|option| option.id == id.as_ref()) - .map(|option| option.label) - .unwrap_or(id.as_ref()) - }) - .collect::>() - .join(", ") - .into() -} - -struct MultiSelectRow { - option_id: &'static str, - option_label: &'static str, - is_selected: bool, - selected_index: Option, -} - -fn render_multi_select_menu( - setting_id: &'static str, - selected: Vec, - options: &'static [DropdownOption], - ordered: bool, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let rows = multi_select_rows(&selected, options); - let last_selected_idx = selected.len().saturating_sub(1); - let mut items: Vec = Vec::with_capacity(rows.len()); - - for (row_idx, row_data) in rows.into_iter().enumerate() { - let item_id = - ElementId::Name(format!("setting-multi-select-{setting_id}-{row_idx}").into()); - let selected_for_row = selected.clone(); - let option_id = row_data.option_id; - - let mut row = div() - .id(item_id) - .h(rems(1.75)) - .min_w_full() - .px_2() - .flex() - .items_center() - .gap_2() - .rounded(rems(0.25)) - .text_sm() - .text_color(if row_data.is_selected { - theme.text_emphasis - } else { - theme.text - }) - .hover(move |this| this.bg(theme.bg_selected)) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - let mut next = selected_for_row.clone(); - if let Some(pos) = next.iter().position(|id| id.as_ref() == option_id) { - next.remove(pos); - } else { - next.push(SharedString::from(option_id)); - } - set_multi_select_value(setting_id, next, cx); - }) - .child(div().flex_1().child(row_data.option_label)); - - if ordered && row_data.is_selected { - if let Some(selected_index) = row_data.selected_index { - let is_first = selected_index == 0; - let is_last = selected_index == last_selected_idx; - - if !is_first { - let selected_up = selected.clone(); - row = row.child( - div() - .id(ElementId::Name( - format!("setting-multi-select-{setting_id}-up-{row_idx}").into(), - )) - .px_1() - .text_color(theme.text_subtle) - .hover(move |this| this.text_color(theme.text_emphasis)) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - let mut next = selected_up.clone(); - next.swap(selected_index, selected_index - 1); - set_multi_select_value(setting_id, next, cx); - }) - .child("↑"), - ); - } - - if !is_last { - let selected_down = selected.clone(); - row = row.child( - div() - .id(ElementId::Name( - format!("setting-multi-select-{setting_id}-down-{row_idx}").into(), - )) - .px_1() - .text_color(theme.text_subtle) - .hover(move |this| this.text_color(theme.text_emphasis)) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - let mut next = selected_down.clone(); - next.swap(selected_index, selected_index + 1); - set_multi_select_value(setting_id, next, cx); - }) - .child("↓"), - ); - } - } - } - - if row_data.is_selected { - row = row.child(div().text_color(theme.accent).child("✓")); - } - - items.push(row.into_any_element()); - } - - div() - .id(make_id("setting-multi-select-menu", setting_id)) - .mt(rems(1.75)) - .min_w(rems(SETTING_MULTI_SELECT_WIDTH_REM)) - .p_1() - .flex() - .flex_col() - .gap_0p5() - .rounded(rems(0.375)) - .border_1() - .border_color(theme.border_strong) - .bg(theme.bg_elevated) - .shadow_lg() - .block_mouse_except_scroll() - .children(items) - .into_any_element() -} - -fn multi_select_rows( - selected: &[SharedString], - options: &'static [DropdownOption], -) -> Vec { - let selected_set: HashSet<&str> = selected.iter().map(|id| id.as_ref()).collect(); - let mut rows = Vec::with_capacity(options.len()); - - for (selected_index, selected_id) in selected.iter().enumerate() { - if let Some(option) = options - .iter() - .find(|option| option.id == selected_id.as_ref()) - { - rows.push(MultiSelectRow { - option_id: option.id, - option_label: option.label, - is_selected: true, - selected_index: Some(selected_index), - }); - } - } - - for option in options { - if !selected_set.contains(option.id) { - rows.push(MultiSelectRow { - option_id: option.id, - option_label: option.label, - is_selected: false, - selected_index: None, - }); - } - } - - rows -} - -fn set_multi_select_value(setting_id: &'static str, selected: Vec, cx: &mut App) { - let list = selected.into_iter().map(SettingValue::String).collect(); - cx.update_global::(|settings, _| { - settings.set(setting_id, SettingValue::List(list)); - }); - cx.refresh_windows(); -} - -fn number_setting_input( - setting_id: &'static str, - value: i64, - min: Option, - max: Option, - step: i64, - window: &mut Window, - cx: &mut Context, -) -> Entity { - let value = clamp_number_value(value, min, max); - let step = step.max(1); - let state = window.use_keyed_state( - make_id("setting-number-state", setting_id), - cx, - |window, cx| { - let input = cx.new(|cx| InputState::new(window, cx).default_value(value.to_string())); - let _subscriptions = vec![ - cx.subscribe_in( - &input, - window, - move |state: &mut NumberSettingState, - input, - event: &NumberInputEvent, - window, - cx| { - match event { - NumberInputEvent::Step(action) => { - let current_value = input - .read(cx) - .value() - .parse::() - .unwrap_or(state.committed_value); - let next_value = match *action { - StepAction::Decrement => current_value.saturating_sub(step), - StepAction::Increment => current_value.saturating_add(step), - }; - state.commit( - input, - setting_id, - clamp_number_value(next_value, min, max), - window, - cx, - ); - } - } - }, - ), - cx.subscribe_in( - &input, - window, - move |state: &mut NumberSettingState, input, event: &InputEvent, window, cx| { - match event { - InputEvent::Change => { - let Some(value) = input.read(cx).value().parse::().ok() else { - return; - }; - if number_value_in_range(value, min, max) { - state.set_committed_value(setting_id, value, cx); - } - } - InputEvent::Blur | InputEvent::PressEnter { .. } => { - let value = input - .read(cx) - .value() - .parse::() - .map(|value| clamp_number_value(value, min, max)) - .unwrap_or(state.committed_value); - state.commit(input, setting_id, value, window, cx); - } - InputEvent::Focus => {} - } - }, - ), - ]; - - NumberSettingState { - input, - committed_value: value, - _subscriptions, - } - }, - ); - - sync_number_setting_input(&state, value, window, cx); - state.read(cx).input.clone() -} - -impl NumberSettingState { - fn commit( - &mut self, - input: &Entity, - setting_id: &'static str, - value: i64, - window: &mut Window, - cx: &mut Context, - ) { - self.set_committed_value(setting_id, value, cx); - set_input_value_if_needed(input, value, window, cx); - } - - fn set_committed_value( - &mut self, - setting_id: &'static str, - value: i64, - cx: &mut Context, - ) { - if self.committed_value == value { - return; - } - self.committed_value = value; - cx.update_global::(|settings, _| { - settings.set(setting_id, SettingValue::Int(value)); - }); - cx.refresh_windows(); - } -} - -fn sync_number_setting_input( - state: &Entity, - value: i64, - window: &mut Window, - cx: &mut Context, -) { - state.update(cx, |state, cx| { - if state.committed_value == value { - return; - } - state.committed_value = value; - set_input_value_if_needed(&state.input, value, window, cx); - }); -} - -fn set_input_value_if_needed( - input: &Entity, - value: i64, - window: &mut Window, - cx: &mut Context, -) { - let value = value.to_string(); - input.update(cx, |input, cx| { - if input.value().as_ref() != value { - input.set_value(value, window, cx); - } - }); -} - -fn clamp_number_value(value: i64, min: Option, max: Option) -> i64 { - value.clamp(min.unwrap_or(i64::MIN), max.unwrap_or(i64::MAX)) -} - -fn number_value_in_range(value: i64, min: Option, max: Option) -> bool { - min.is_none_or(|min| value >= min) && max.is_none_or(|max| value <= max) -} diff --git a/crates/ui/src/tabs/settings/mod.rs b/crates/ui/src/tabs/settings/mod.rs deleted file mode 100644 index 151c6ae4..00000000 --- a/crates/ui/src/tabs/settings/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod cards; -mod content; -mod controls; -pub mod state; - -pub use state::SettingsInputs; - -use gpui::{AnyElement, Context, IntoElement, Window, div, prelude::*}; - -use crate::delegate::{ActiveSettingsUi, SettingsDelegate}; -use crate::tabs::settings::state::ActiveSettingsInputs; - -pub fn render(window: &mut Window, cx: &mut Context) -> AnyElement { - let open_dropdown = cx.settings_ui().open_dropdown; - let search = cx.settings_inputs().search(); - - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .child(content::render(search, open_dropdown, window, cx)) - .into_any_element() -} diff --git a/crates/ui/src/tabs/settings/state.rs b/crates/ui/src/tabs/settings/state.rs deleted file mode 100644 index 4ce095ea..00000000 --- a/crates/ui/src/tabs/settings/state.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::collections::HashMap; - -use gpui::{App, AppContext, BorrowAppContext, Context, Entity, Global, Window}; -use gpui_component::input::{InputEvent, InputState}; - -use settings::{SettingControl, SettingValue, Settings}; - -#[derive(Default)] -pub struct SettingsInputs { - search: Option>, - configs: HashMap<&'static str, (String, &'static str)>, - entities: HashMap<&'static str, Entity>, -} - -impl SettingsInputs { - pub fn install(window: &mut Window, cx: &mut Context) { - let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search settings")); - cx.subscribe(&search, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }) - .detach(); - - let mut configs = HashMap::new(); - for category in Settings::categories() { - for setting in category.settings { - let SettingControl::Input { placeholder, .. } = &setting.control else { - continue; - }; - let placeholder = placeholder.unwrap_or(""); - let initial = cx - .global::() - .value(setting) - .as_str() - .unwrap_or("") - .to_string(); - configs.insert(setting.id, (initial, placeholder)); - } - } - - let mut inputs = Self { - search: Some(search), - configs, - entities: HashMap::new(), - }; - - for setting_id in inputs.configs.keys().copied().collect::>() { - let (initial, placeholder) = inputs.configs.get(setting_id).unwrap(); - let entity = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(*placeholder) - .default_value(initial.clone()) - }); - let entity_for_sub = entity.clone(); - cx.subscribe(&entity, move |_, _, event: &InputEvent, cx| { - if let InputEvent::Change = event { - let value = entity_for_sub.read(cx).value(); - cx.update_global::(|settings, _| { - settings.set(setting_id, SettingValue::String(value)); - }); - } - }) - .detach(); - inputs.entities.insert(setting_id, entity); - } - - cx.set_global(inputs); - } - - pub fn get(&self, setting_id: &str) -> Option> { - self.entities.get(setting_id).cloned() - } - - pub fn search(&self) -> Option> { - self.search.clone() - } -} - -impl Global for SettingsInputs {} - -pub trait ActiveSettingsInputs { - fn settings_inputs(&self) -> &SettingsInputs; -} - -impl ActiveSettingsInputs for App { - fn settings_inputs(&self) -> &SettingsInputs { - self.global::() - } -} diff --git a/crates/ui/src/tabs/terminal/mod.rs b/crates/ui/src/tabs/terminal/mod.rs deleted file mode 100644 index 19404bea..00000000 --- a/crates/ui/src/tabs/terminal/mod.rs +++ /dev/null @@ -1,2260 +0,0 @@ -use std::cell::Cell as StdCell; -use std::path::Path; -use std::rc::Rc; -use std::sync::{Arc, OnceLock}; - -use gpui::{ - Anchor, AnyElement, App, Bounds, ClipboardItem, Context, Element, ElementId, - ElementInputHandler, Entity, FocusHandle, FontFeatures, Global, GlobalElementId, - InteractiveElement, IntoElement, KeyBinding, KeyDownEvent, LayoutId, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ShapedLine, SharedString, - StrikethroughStyle, Style, TextRun, UnderlineStyle, Window, actions, canvas, div, fill, - outline, point, prelude::*, px, relative, rems, rgb, -}; -use gpui_component::{ - Icon as ComponentIcon, Sizable, - button::{Button, ButtonVariants}, - menu::{DropdownMenu, PopupMenuItem}, - separator::Separator, -}; -use icons::IconName; -use terminal::{ - ShellProfile, TerminalCell, TerminalCellRun, TerminalColor, TerminalCursorShape, TerminalKey, - TerminalKeyInput, TerminalMouseButton, TerminalMouseModifiers, TerminalPalette, - TerminalSelectionMode, TerminalSelectionRange, TerminalSession, TerminalSnapshot, - TerminalStatus, TerminalStore, TerminalStyle, TerminalTheme, -}; -use theme::{ActiveTheme, Theme}; - -const KEY_CONTEXT: &str = "Terminal"; -const BASE_FONT_SIZE_REM: f32 = 0.875; -const BASE_ROW_HEIGHT_REM: f32 = 1.15625; -const BASE_CELL_WIDTH_REM: f32 = 0.54; -const CELL_WIDTH_SAMPLE_COLUMNS: usize = 64; -const MONOSPACE_SAMPLE_REPETITIONS: usize = 8; -const MONOSPACE_WIDTH_TOLERANCE_REM: f32 = 0.01; -const TERMINAL_ASCII_WIDTH_SAMPLE: [char; 5] = ['i', 'm', 'W', '0', 'A']; -// Font selection probes these terminal drawing glyphs; rendering remains font-driven. -const TERMINAL_DRAWING_WIDTH_SAMPLE: [char; 4] = ['\u{2502}', '\u{2580}', '\u{2584}', '\u{2588}']; -const BOTTOM_BAR_HEIGHT_REM: f32 = 1.75; -const BOTTOM_BAR_BUTTON_SIZE_REM: f32 = 1.375; -const SHELL_PICKER_WIDTH_REM: f32 = 8.0; - -actions!(terminal, [InsertTab, InsertBacktab]); - -#[derive(Default)] -pub struct TerminalUi; - -impl TerminalUi { - pub fn install(cx: &mut App) { - cx.set_global(Self::default()); - } - - pub fn close_shell_picker(&mut self) -> bool { - false - } -} - -impl Global for TerminalUi {} - -pub fn install_default_keybindings(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("tab", InsertTab, Some(KEY_CONTEXT)), - KeyBinding::new("shift-tab", InsertBacktab, Some(KEY_CONTEXT)), - ]); -} - -pub fn render( - workspace_id: usize, - cwd: &Path, - tab_id: usize, - window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let key = TerminalKey::new(workspace_id, tab_id); - let session = TerminalStore::for_tab(key, cwd.to_path_buf(), cx); - observe_session(&session, cx); - let theme = *cx.theme(); - let terminal_theme = terminal_theme(theme); - session.update(cx, |session, _| session.set_theme(terminal_theme)); - - let snapshot = session.update(cx, |session, _| session.snapshot()); - let screen_background = dominant_background_color(&snapshot, terminal_theme.background); - let selection_color = - gpui::Hsla::from(theme.accent).opacity(if theme.is_dark { 0.35 } else { 0.25 }); - let metrics = terminal_metrics( - snapshot.zoom_percent, - screen_background, - snapshot.cursor_color, - selection_color, - window, - ); - let focus_handle = session.read(cx).focus_handle(); - let is_focused = focus_handle.is_focused(window); - - let bottom_bar = render_bottom_bar(&session, &snapshot, cx); - let screen = render_screen( - &session, - snapshot, - metrics, - focus_handle, - is_focused, - window, - cx, - ); - - div() - .size_full() - .min_w_0() - .min_h_0() - .flex() - .flex_col() - .bg(rgba_for_terminal_color(screen_background)) - .child(screen) - .child(bottom_bar) - .into_any_element() -} - -fn terminal_theme(theme: Theme) -> TerminalTheme { - TerminalTheme::new( - TerminalPalette::for_dark_theme(theme.is_dark), - terminal_color_from_rgba(theme.terminal_foreground), - terminal_color_from_rgba(theme.terminal_background), - ) -} - -fn terminal_color_from_rgba(color: gpui::Rgba) -> TerminalColor { - TerminalColor { - r: rgba_component_to_u8(color.r), - g: rgba_component_to_u8(color.g), - b: rgba_component_to_u8(color.b), - } -} - -fn rgba_component_to_u8(component: f32) -> u8 { - (component.clamp(0.0, 1.0) * 255.0).round() as u8 -} - -fn observe_session(session: &Entity, cx: &mut Context) { - if session.read(cx).observed_by_ui() { - return; - } - session.update(cx, |session, _| session.mark_observed_by_ui()); - cx.observe(session, |_, _, cx| cx.notify()).detach(); -} - -#[derive(Clone, Copy)] -struct TerminalMetrics { - font_family: &'static str, - font_size_rem: f32, - row_height_rem: f32, - cell_width_rem: f32, - screen_background: TerminalColor, - cursor_color: TerminalColor, - selection_color: gpui::Hsla, -} - -#[derive(Clone, Copy)] -struct TerminalBackgroundRect { - row: usize, - rows: usize, - column: usize, - width: usize, - color: TerminalColor, -} - -struct TerminalPaintRect { - bounds: Bounds, - color: TerminalColor, -} - -#[derive(Clone, Copy)] -struct TerminalGridPixels { - origin: Point, - cell_width: Pixels, - row_height: Pixels, - font_size: Pixels, - underline_thickness: Pixels, - cursor_thickness: Pixels, -} - -struct TerminalPaintTextRun { - origin: Point, - line: ShapedLine, -} - -struct TerminalPaintBuffers<'a> { - text_runs: &'a mut Vec, - custom_glyph_rects: &'a mut Vec, -} - -struct TerminalTextRunBuilder { - row: usize, - column: usize, - width: usize, - text: String, - style: TerminalStyle, -} - -struct TerminalPaintCustomGlyphRect { - bounds: Bounds, - color: gpui::Hsla, -} - -#[derive(Clone, Copy)] -enum TerminalLineWeight { - Light, - Heavy, -} - -#[derive(Clone, Copy)] -struct TerminalBoxDrawingGlyph { - up: Option, - right: Option, - down: Option, - left: Option, -} - -struct TerminalPaintCursor { - bounds: Bounds, - shape: TerminalCursorShape, -} - -struct TerminalSurfacePaintState { - background_rects: Vec, - selection_rects: Vec, - custom_glyph_rects: Vec, - text_runs: Vec, -} - -fn terminal_metrics( - zoom_percent: i64, - screen_background: TerminalColor, - cursor_color: TerminalColor, - selection_color: gpui::Hsla, - window: &mut Window, -) -> TerminalMetrics { - let font_family = terminal_font_family(window); - let scale = zoom_percent as f32 / 100.0; - let font_size_rem = BASE_FONT_SIZE_REM * scale; - let cell_width_rem = terminal_base_cell_width_rem(font_family, window) * scale; - TerminalMetrics { - font_family, - font_size_rem, - row_height_rem: BASE_ROW_HEIGHT_REM * scale, - cell_width_rem, - screen_background, - cursor_color, - selection_color, - } -} - -fn terminal_base_cell_width_rem(font_family: &str, window: &mut Window) -> f32 { - static TERMINAL_BASE_CELL_WIDTH_REM: OnceLock = OnceLock::new(); - *TERMINAL_BASE_CELL_WIDTH_REM.get_or_init(|| { - measure_terminal_cell_width_rem(font_family, BASE_FONT_SIZE_REM, window) - .unwrap_or(BASE_CELL_WIDTH_REM) - }) -} - -fn terminal_font_family(window: &mut Window) -> &'static str { - static TERMINAL_FONT_FAMILY: OnceLock = OnceLock::new(); - TERMINAL_FONT_FAMILY.get_or_init(|| choose_terminal_font_family(window)) -} - -fn choose_terminal_font_family(window: &mut Window) -> String { - let mut best_font: Option<(String, f32)> = None; - for font_family in window.text_system().all_font_names() { - if font_family.starts_with('.') { - continue; - } - let Some(score) = terminal_font_score(&font_family, window) else { - continue; - }; - let is_better = best_font - .as_ref() - .is_none_or(|(_, best_score)| score < *best_score); - if is_better { - best_font = Some((font_family, score)); - } - } - - best_font - .map(|(font_family, _)| font_family) - .unwrap_or_else(|| ".SystemUIFont".to_string()) -} - -fn terminal_font_score(font_family: &str, window: &mut Window) -> Option { - let space_width = repeated_character_width_rem(font_family, ' ', window)?; - - let mut score = 0.0; - for character in TERMINAL_ASCII_WIDTH_SAMPLE { - let width = repeated_character_width_rem(font_family, character, window)?; - let deviation = (width - space_width).abs(); - if deviation > MONOSPACE_WIDTH_TOLERANCE_REM { - return None; - } - score += deviation; - } - - for character in TERMINAL_DRAWING_WIDTH_SAMPLE { - let width = repeated_character_width_rem(font_family, character, window)?; - score += (width - space_width).abs(); - } - - Some(score) -} - -fn repeated_character_width_rem(font_family: &str, ch: char, window: &mut Window) -> Option { - let sample = ch.to_string().repeat(MONOSPACE_SAMPLE_REPETITIONS); - let total_width = - measure_terminal_text_width_rem(font_family, BASE_FONT_SIZE_REM, sample, window)?; - Some(total_width / MONOSPACE_SAMPLE_REPETITIONS as f32) -} - -fn terminal_font(font_family: &str) -> gpui::Font { - let mut font = gpui::font(font_family.to_string()); - font.features = FontFeatures::disable_ligatures(); - font -} - -fn measure_terminal_cell_width_rem( - font_family: &str, - font_size_rem: f32, - window: &mut Window, -) -> Option { - let text = " ".repeat(CELL_WIDTH_SAMPLE_COLUMNS); - let total_width = measure_terminal_text_width_rem(font_family, font_size_rem, text, window)?; - Some(total_width / CELL_WIDTH_SAMPLE_COLUMNS as f32) -} - -fn measure_terminal_text_width_rem( - font_family: &str, - font_size_rem: f32, - text: String, - window: &mut Window, -) -> Option { - let mut text_style = window.text_style(); - text_style.font_family = SharedString::from(font_family.to_string()); - text_style.font_features = FontFeatures::disable_ligatures(); - text_style.font_size = rems(font_size_rem).into(); - - let text = SharedString::from(text); - let text_len = text.len(); - let run = text_style.to_run(text.len()); - let rem_size = window.rem_size(); - let font_size = rems(font_size_rem).to_pixels(rem_size); - let line_height = rems(BASE_ROW_HEIGHT_REM).to_pixels(rem_size); - let lines = window - .text_system() - .shape_text(text, font_size, &[run], None, None) - .ok()?; - let width = lines.first()?.position_for_index(text_len, line_height)?.x; - let width_rem = f32::from(width) / f32::from(rem_size); - (width_rem > 0.0).then_some(width_rem) -} - -fn dominant_background_color( - snapshot: &TerminalSnapshot, - fallback_background: TerminalColor, -) -> TerminalColor { - let mut weights: Vec<(TerminalColor, usize)> = Vec::new(); - for row in &snapshot.rows { - for run in &row.cell_runs { - if let Some((_, weight)) = weights - .iter_mut() - .find(|(color, _)| *color == run.style.background) - { - *weight += run.width; - } else { - weights.push((run.style.background, run.width)); - } - } - } - - weights - .into_iter() - .max_by_key(|(_, weight)| *weight) - .map(|(color, _)| color) - .unwrap_or(fallback_background) -} - -fn render_screen( - session: &Entity, - snapshot: Arc, - metrics: TerminalMetrics, - focus_handle: FocusHandle, - is_focused: bool, - _window: &mut Window, - cx: &mut Context, -) -> AnyElement { - let session_for_resize = session.clone(); - let session_for_key = session.clone(); - let session_for_tab = session.clone(); - let session_for_backtab = session.clone(); - let session_for_copy = session.clone(); - let session_for_paste = session.clone(); - let session_for_scroll = session.clone(); - let session_for_mouse_down = session.clone(); - let session_for_mouse_move = session.clone(); - let session_for_mouse_up_left = session.clone(); - let session_for_mouse_up_middle = session.clone(); - let session_for_mouse_up_right = session.clone(); - let session_for_mouse_up_out = session.clone(); - let focus_for_click = focus_handle.clone(); - let terminal_bounds = Rc::new(StdCell::new(None)); - let bounds_for_mouse_down = terminal_bounds.clone(); - let bounds_for_mouse_move = terminal_bounds.clone(); - let bounds_for_mouse_up_left = terminal_bounds.clone(); - let bounds_for_mouse_up_middle = terminal_bounds.clone(); - let bounds_for_mouse_up_right = terminal_bounds.clone(); - let bounds_for_mouse_up_out = terminal_bounds.clone(); - let bounds_for_scroll = terminal_bounds.clone(); - let bounds_for_resize = terminal_bounds.clone(); - let columns = snapshot.columns; - let rows = snapshot.screen_rows; - let cursor = snapshot.cursor; - let surface = render_terminal_surface(snapshot, metrics); - - div() - .relative() - .flex_1() - .min_h_0() - .min_w_0() - .overflow_hidden() - .track_focus(&focus_handle) - .key_context(KEY_CONTEXT) - .font(terminal_font(metrics.font_family)) - .text_size(rems(metrics.font_size_rem)) - .line_height(rems(metrics.row_height_rem)) - .bg(rgba_for_terminal_color(metrics.screen_background)) - .text_color(rgba_for_terminal_color(metrics.cursor_color)) - .on_action(cx.listener(move |_, _: &InsertTab, _, cx| { - write_terminal_key( - &session_for_tab, - TerminalKeyInput { - key: "tab".to_string(), - ..Default::default() - }, - cx, - ); - })) - .on_action(cx.listener(move |_, _: &InsertBacktab, _, cx| { - write_terminal_key( - &session_for_backtab, - TerminalKeyInput { - key: "tab".to_string(), - shift: true, - ..Default::default() - }, - cx, - ); - })) - .on_any_mouse_down(cx.listener(move |_, event: &MouseDownEvent, window, cx| { - window.focus(&focus_for_click, cx); - let Some(button) = terminal_mouse_button(event.button) else { - return; - }; - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_down.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_down.update(cx, |session, cx| { - let selection_mode = if event.click_count >= 3 { - TerminalSelectionMode::Line - } else if event.click_count == 2 { - TerminalSelectionMode::Word - } else { - TerminalSelectionMode::Cell - }; - session.mouse_down( - row, - column, - button, - selection_mode, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }); - if handled { - cx.stop_propagation(); - } - })) - .on_mouse_move(cx.listener(move |_, event: &MouseMoveEvent, window, cx| { - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_move.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_move.update(cx, |session, cx| { - session.mouse_move(row, column, terminal_mouse_modifiers(event.modifiers), cx) - }); - if handled { - cx.stop_propagation(); - } - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |_, event: &MouseUpEvent, window, cx| { - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_up_left.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_up_left.update(cx, |session, cx| { - session.mouse_up( - row, - column, - TerminalMouseButton::Left, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }); - if handled { - cx.stop_propagation(); - } - }), - ) - .on_mouse_up( - MouseButton::Middle, - cx.listener(move |_, event: &MouseUpEvent, window, cx| { - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_up_middle.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_up_middle.update(cx, |session, cx| { - session.mouse_up( - row, - column, - TerminalMouseButton::Middle, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }); - if handled { - cx.stop_propagation(); - } - }), - ) - .on_mouse_up( - MouseButton::Right, - cx.listener(move |_, event: &MouseUpEvent, window, cx| { - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_up_right.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_up_right.update(cx, |session, cx| { - session.mouse_up( - row, - column, - TerminalMouseButton::Right, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }); - if handled { - cx.stop_propagation(); - } - }), - ) - .on_mouse_up_out( - MouseButton::Left, - move |event: &MouseUpEvent, window: &mut Window, cx: &mut App| { - let Some((row, column)) = terminal_grid_position_for_point( - event.position, - bounds_for_mouse_up_out.get(), - metrics, - window, - columns, - rows, - ) else { - return; - }; - let handled = session_for_mouse_up_out.update(cx, |session, cx| { - session.mouse_up( - row, - column, - TerminalMouseButton::Left, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }); - if handled { - cx.stop_propagation(); - } - }, - ) - .capture_key_down(cx.listener(move |_, event: &KeyDownEvent, _window, cx| { - if is_terminal_copy(event) { - if let Some(text) = - session_for_copy.update(cx, |session, _| session.selected_text()) - { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } - cx.stop_propagation(); - return; - } - - if is_terminal_paste(event) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - session_for_paste.update(cx, |session, cx| { - session.paste(&text); - cx.notify(); - }); - } - cx.stop_propagation(); - return; - } - - let input = terminal_key_input(event); - write_terminal_key(&session_for_key, input, cx); - })) - .on_scroll_wheel(move |event, window, cx| { - let row_height = terminal_row_height_pixels(metrics, window); - let delta = event.delta.pixel_delta(row_height); - let lines = if row_height == Pixels::ZERO { - 0 - } else { - (delta.y / row_height).round() as i32 - }; - if lines != 0 { - let handled = terminal_grid_position_for_point( - event.position, - bounds_for_scroll.get(), - metrics, - window, - columns, - rows, - ) - .is_some_and(|(row, column)| { - session_for_scroll.update(cx, |session, cx| { - session.scroll_lines_at( - row, - column, - lines, - terminal_mouse_modifiers(event.modifiers), - cx, - ) - }) - }); - if !handled { - session_for_scroll.update(cx, |session, cx| session.scroll_lines(lines, cx)); - } - cx.stop_propagation(); - } - }) - .child( - div() - .size_full() - .min_w_0() - .min_h_0() - .relative() - .child(surface) - .when_some(cursor, |this, cursor| { - this.child(render_cursor(cursor, metrics, is_focused)) - }) - .child( - div() - .absolute() - .top_0() - .right_0() - .bottom_0() - .left_0() - .child(TerminalInputElement { - session: session.clone(), - }), - ), - ) - .on_children_prepainted(move |bounds, window, cx| { - let Some(bounds) = bounds.first().copied() else { - return; - }; - bounds_for_resize.set(Some(bounds)); - resize_terminal_to_bounds(&session_for_resize, bounds, metrics, window, cx); - }) - .into_any_element() -} - -fn render_terminal_surface( - snapshot: Arc, - metrics: TerminalMetrics, -) -> AnyElement { - canvas( - move |bounds, window, _cx| { - let grid = terminal_grid_pixels(bounds, metrics, window); - let background_rects = terminal_background_rects(&snapshot, metrics.screen_background) - .iter() - .map(|rect| TerminalPaintRect { - bounds: snapped_terminal_rect(grid, rect), - color: rect.color, - }) - .collect::>(); - let selection_rects = - terminal_selection_rects(&snapshot.selection_ranges, grid, metrics); - let mut text_runs = Vec::new(); - let mut custom_glyph_rects = Vec::new(); - { - let mut paint_buffers = TerminalPaintBuffers { - text_runs: &mut text_runs, - custom_glyph_rects: &mut custom_glyph_rects, - }; - for (row_index, row) in snapshot.rows.iter().enumerate() { - for run in &row.cell_runs { - push_terminal_cell_run( - row_index, - run, - &row.cells, - grid, - metrics, - window, - &mut paint_buffers, - ); - } - } - } - - TerminalSurfacePaintState { - background_rects, - selection_rects, - custom_glyph_rects, - text_runs, - } - }, - move |bounds, paint_state, window, cx| { - window.paint_quad(fill( - bounds, - rgba_for_terminal_color(metrics.screen_background), - )); - for rect in paint_state.background_rects { - window.paint_quad(fill(rect.bounds, rgba_for_terminal_color(rect.color))); - } - for rect in paint_state.selection_rects { - window.paint_quad(fill(rect.bounds, rect.color)); - } - for rect in paint_state.custom_glyph_rects { - window.paint_quad(fill(rect.bounds, rect.color)); - } - let row_height = terminal_grid_pixels(bounds, metrics, window).row_height; - for run in paint_state.text_runs { - let _ = run.line.paint( - run.origin, - row_height, - gpui::TextAlign::default(), - None, - window, - cx, - ); - } - }, - ) - .absolute() - .top_0() - .right_0() - .bottom_0() - .left_0() - .into_any_element() -} - -fn terminal_grid_pixels( - bounds: Bounds, - metrics: TerminalMetrics, - window: &mut Window, -) -> TerminalGridPixels { - let rem_size = window.rem_size(); - TerminalGridPixels { - origin: point( - floor_to_device_pixels(bounds.left(), window), - floor_to_device_pixels(bounds.top(), window), - ), - cell_width: terminal_cell_width_pixels(metrics, window), - row_height: terminal_row_height_pixels(metrics, window), - font_size: rems(metrics.font_size_rem).to_pixels(rem_size), - underline_thickness: rems(0.0625).to_pixels(rem_size), - cursor_thickness: round_to_device_pixels(rems(0.125).to_pixels(rem_size), window) - .max(px(1.0)), - } -} - -fn floor_to_device_pixels(value: Pixels, window: &Window) -> Pixels { - let scale_factor = window.scale_factor().max(1.0); - px((f32::from(value) * scale_factor).floor() / scale_factor) -} - -fn round_to_device_pixels(value: Pixels, window: &Window) -> Pixels { - let scale_factor = window.scale_factor().max(1.0); - px((f32::from(value) * scale_factor).round() / scale_factor) -} - -fn terminal_cell_width_pixels(metrics: TerminalMetrics, window: &mut Window) -> Pixels { - round_to_device_pixels( - rems(metrics.cell_width_rem).to_pixels(window.rem_size()), - window, - ) - .max(px(1.0)) -} - -fn terminal_row_height_pixels(metrics: TerminalMetrics, window: &mut Window) -> Pixels { - round_to_device_pixels( - rems(metrics.row_height_rem).to_pixels(window.rem_size()), - window, - ) - .max(px(1.0)) -} - -fn snapped_terminal_rect( - grid: TerminalGridPixels, - rect: &TerminalBackgroundRect, -) -> Bounds { - terminal_cell_bounds(rect.row, rect.column, rect.rows, rect.width, grid) -} - -fn shape_terminal_text_run( - text: String, - style: TerminalStyle, - origin: Point, - grid: TerminalGridPixels, - metrics: TerminalMetrics, - window: &mut Window, -) -> Option { - if !terminal_text_has_visible_content(&text, style) { - return None; - } - - let text = SharedString::from(text); - let text_run = terminal_text_run(text.len(), style, metrics, grid.underline_thickness); - let line = - window - .text_system() - .shape_line(text, grid.font_size, &[text_run], Some(grid.cell_width)); - - Some(TerminalPaintTextRun { origin, line }) -} - -fn push_terminal_cell_run( - row: usize, - run: &TerminalCellRun, - cells: &[TerminalCell], - grid: TerminalGridPixels, - metrics: TerminalMetrics, - window: &mut Window, - paint_buffers: &mut TerminalPaintBuffers<'_>, -) { - if !terminal_text_has_visible_content(&run.text, run.style) { - return; - } - - if terminal_text_contains_custom_glyph_candidate(&run.text) { - push_terminal_mixed_cell_run(row, run, cells, grid, metrics, window, paint_buffers); - return; - } - - let origin = point( - grid.origin.x + grid.cell_width * run.column, - grid.origin.y + grid.row_height * row, - ); - if let Some(text_run) = - shape_terminal_text_run(run.text.clone(), run.style, origin, grid, metrics, window) - { - paint_buffers.text_runs.push(text_run); - } -} - -fn push_terminal_mixed_cell_run( - row: usize, - run: &TerminalCellRun, - cells: &[TerminalCell], - grid: TerminalGridPixels, - metrics: TerminalMetrics, - window: &mut Window, - paint_buffers: &mut TerminalPaintBuffers<'_>, -) { - let mut text_builder: Option = None; - let run_end = run.column + run.width; - - for cell in cells { - if cell.column >= run_end { - break; - } - if cell.column + cell.width <= run.column { - continue; - } - - let cell_bounds = terminal_cell_bounds(row, cell.column, 1, cell.width, grid); - if let Some(glyph_rects) = terminal_custom_glyph_rects(cell, cell_bounds) { - flush_terminal_text_run( - &mut text_builder, - grid, - metrics, - window, - paint_buffers.text_runs, - ); - paint_buffers.custom_glyph_rects.extend(glyph_rects); - } else { - push_terminal_text_cell( - &mut text_builder, - row, - cell, - grid, - metrics, - window, - paint_buffers.text_runs, - ); - } - } - - flush_terminal_text_run( - &mut text_builder, - grid, - metrics, - window, - paint_buffers.text_runs, - ); -} - -fn push_terminal_text_cell( - builder: &mut Option, - row: usize, - cell: &TerminalCell, - grid: TerminalGridPixels, - metrics: TerminalMetrics, - window: &mut Window, - text_runs: &mut Vec, -) { - if !terminal_cell_has_visible_text(cell) && builder.is_none() { - return; - } - - if let Some(builder) = builder.as_mut() - && builder.style == cell.style - && builder.row == row - && builder.column + builder.width == cell.column - { - builder.text.push_str(&cell.text); - builder.width += cell.width; - return; - } - - flush_terminal_text_run(builder, grid, metrics, window, text_runs); - - if terminal_cell_has_visible_text(cell) { - *builder = Some(TerminalTextRunBuilder { - row, - column: cell.column, - width: cell.width, - text: cell.text.clone(), - style: cell.style, - }); - } -} - -fn flush_terminal_text_run( - builder: &mut Option, - grid: TerminalGridPixels, - metrics: TerminalMetrics, - window: &mut Window, - text_runs: &mut Vec, -) { - let Some(builder) = builder.take() else { - return; - }; - let origin = point( - grid.origin.x + grid.cell_width * builder.column, - grid.origin.y + grid.row_height * builder.row, - ); - if let Some(text_run) = - shape_terminal_text_run(builder.text, builder.style, origin, grid, metrics, window) - { - text_runs.push(text_run); - } -} - -fn terminal_cell_has_visible_text(cell: &TerminalCell) -> bool { - terminal_text_has_visible_content(&cell.text, cell.style) -} - -fn terminal_text_has_visible_content(text: &str, style: TerminalStyle) -> bool { - style.underline || style.strikeout || text.chars().any(|character| !character.is_whitespace()) -} - -fn terminal_text_contains_custom_glyph_candidate(text: &str) -> bool { - text.chars().any(is_terminal_custom_glyph_candidate) -} - -fn is_terminal_custom_glyph_candidate(character: char) -> bool { - matches!( - character as u32, - 0x2500..=0x259f | 0x2800..=0x28ff | 0x1fb00..=0x1fbff - ) -} - -fn terminal_custom_glyph_rects( - cell: &TerminalCell, - bounds: Bounds, -) -> Option> { - let character = single_terminal_character(&cell.text)?; - let color = terminal_style_foreground(cell.style); - terminal_block_element_rects(character, bounds, color) - .or_else(|| terminal_legacy_computing_rects(character, bounds, color)) - .or_else(|| terminal_braille_rects(character, bounds, color)) - .or_else(|| terminal_box_drawing_rects(character, bounds, color)) -} - -fn single_terminal_character(text: &str) -> Option { - let mut characters = text.chars(); - let character = characters.next()?; - characters.next().is_none().then_some(character) -} - -fn terminal_block_element_rects( - character: char, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let codepoint = character as u32; - let mut rects = Vec::new(); - - match codepoint { - 0x2580 => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.0, 0.0, 1.0, 0.5) - } - 0x2581..=0x2588 => { - let height = (codepoint - 0x2580) as f32 / 8.0; - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.0, - 1.0 - height, - 1.0, - height, - ); - } - 0x2589..=0x258f => { - let width = (0x2590 - codepoint) as f32 / 8.0; - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.0, 0.0, width, 1.0); - } - 0x2590 => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.5, 0.0, 0.5, 1.0) - } - 0x2591 => push_terminal_custom_glyph_rect(&mut rects, bounds, color.opacity(0.25)), - 0x2592 => push_terminal_custom_glyph_rect(&mut rects, bounds, color.opacity(0.5)), - 0x2593 => push_terminal_custom_glyph_rect(&mut rects, bounds, color.opacity(0.75)), - 0x2594 => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.0, 0.0, 1.0, 0.125) - } - 0x2595 => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.875, 0.0, 0.125, 1.0) - } - 0x2596..=0x259f => push_terminal_quadrant_glyph(&mut rects, codepoint, bounds, color), - _ => return None, - } - - Some(rects) -} - -fn terminal_legacy_computing_rects( - character: char, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let codepoint = character as u32; - - terminal_sextant_mask(codepoint) - .map(|mask| terminal_sextant_rects(mask, bounds, color)) - .or_else(|| terminal_legacy_octant_rects(codepoint, bounds, color)) - .or_else(|| terminal_legacy_shade_rects(codepoint, bounds, color)) -} - -fn terminal_sextant_mask(codepoint: u32) -> Option { - const SEXTANT_MASKS: [u8; 60] = [ - 0b000001, 0b000010, 0b000011, 0b000100, 0b000101, 0b000110, 0b000111, 0b001000, 0b001001, - 0b001010, 0b001011, 0b001100, 0b001101, 0b001110, 0b001111, 0b010000, 0b010001, 0b010010, - 0b010011, 0b010100, 0b010110, 0b010111, 0b011000, 0b011001, 0b011010, 0b011011, 0b011100, - 0b011101, 0b011110, 0b011111, 0b100000, 0b100001, 0b100010, 0b100011, 0b100100, 0b100101, - 0b100110, 0b100111, 0b101000, 0b101001, 0b101011, 0b101100, 0b101101, 0b101110, 0b101111, - 0b110000, 0b110001, 0b110010, 0b110011, 0b110100, 0b110101, 0b110110, 0b110111, 0b111000, - 0b111001, 0b111010, 0b111011, 0b111100, 0b111101, 0b111110, - ]; - - let index = codepoint.checked_sub(0x1fb00)? as usize; - SEXTANT_MASKS.get(index).copied() -} - -fn terminal_sextant_rects( - mask: u8, - bounds: Bounds, - color: gpui::Hsla, -) -> Vec { - let mut rects = Vec::new(); - let row_tops = [0.0, 3.0 / 8.0, 5.0 / 8.0]; - let row_heights = [3.0 / 8.0, 2.0 / 8.0, 3.0 / 8.0]; - - for row in 0..3 { - let left_bit = mask & (1 << (row * 2)) != 0; - let right_bit = mask & (1 << (row * 2 + 1)) != 0; - match (left_bit, right_bit) { - (true, true) => push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.0, - row_tops[row], - 1.0, - row_heights[row], - ), - (true, false) => push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.0, - row_tops[row], - 0.5, - row_heights[row], - ), - (false, true) => push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.5, - row_tops[row], - 0.5, - row_heights[row], - ), - (false, false) => {} - } - } - - rects -} - -fn terminal_legacy_octant_rects( - codepoint: u32, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let mut rects = Vec::new(); - - match codepoint { - 0x1fb70..=0x1fb75 => { - let column = (codepoint - 0x1fb70 + 1) as f32; - push_terminal_octant_glyph(&mut rects, bounds, color, column, 0.0, 1.0, 8.0); - } - 0x1fb76..=0x1fb7b => { - let row = (codepoint - 0x1fb76 + 1) as f32; - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, row, 8.0, 1.0); - } - 0x1fb7c => { - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 1.0, 8.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 7.0, 8.0, 1.0); - } - 0x1fb7d => { - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 1.0, 8.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 8.0, 1.0); - } - 0x1fb7e => { - push_terminal_octant_glyph(&mut rects, bounds, color, 7.0, 0.0, 1.0, 8.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 8.0, 1.0); - } - 0x1fb7f => { - push_terminal_octant_glyph(&mut rects, bounds, color, 7.0, 0.0, 1.0, 8.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 7.0, 8.0, 1.0); - } - 0x1fb80 => { - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 8.0, 1.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 7.0, 8.0, 1.0); - } - 0x1fb81 => { - for row in [0.0, 2.0, 4.0, 7.0] { - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, row, 8.0, 1.0); - } - } - 0x1fb82..=0x1fb86 => { - let height = match codepoint { - 0x1fb82 => 2.0, - 0x1fb83 => 3.0, - 0x1fb84 => 5.0, - 0x1fb85 => 6.0, - _ => 7.0, - }; - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 0.0, 8.0, height); - } - 0x1fb87..=0x1fb8b => { - let (left, width) = match codepoint { - 0x1fb87 => (6.0, 2.0), - 0x1fb88 => (5.0, 3.0), - 0x1fb89 => (3.0, 5.0), - 0x1fb8a => (2.0, 6.0), - _ => (1.0, 7.0), - }; - push_terminal_octant_glyph(&mut rects, bounds, color, left, 0.0, width, 8.0); - } - 0x1fb95 => push_terminal_checker_glyph(&mut rects, bounds, color, false), - 0x1fb96 => push_terminal_checker_glyph(&mut rects, bounds, color, true), - 0x1fb97 => { - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 2.0, 8.0, 2.0); - push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 6.0, 8.0, 2.0); - } - 0x1fbce => { - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.0, - 0.0, - 2.0 / 3.0, - 1.0, - ); - } - 0x1fbcf => { - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - color, - 0.0, - 0.0, - 1.0 / 3.0, - 1.0, - ); - } - 0x1fbe4 => push_terminal_octant_glyph(&mut rects, bounds, color, 2.0, 0.0, 4.0, 4.0), - 0x1fbe5 => push_terminal_octant_glyph(&mut rects, bounds, color, 2.0, 4.0, 4.0, 4.0), - 0x1fbe6 => push_terminal_octant_glyph(&mut rects, bounds, color, 0.0, 2.0, 4.0, 4.0), - 0x1fbe7 => push_terminal_octant_glyph(&mut rects, bounds, color, 4.0, 2.0, 4.0, 4.0), - _ => return None, - } - - Some(rects) -} - -fn terminal_legacy_shade_rects( - codepoint: u32, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let mut rects = Vec::new(); - let shade_color = color.opacity(0.5); - - match codepoint { - 0x1fb8c => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, shade_color, 0.0, 0.0, 0.5, 1.0) - } - 0x1fb8d => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, shade_color, 0.5, 0.0, 0.5, 1.0) - } - 0x1fb8e => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, shade_color, 0.0, 0.0, 1.0, 0.5) - } - 0x1fb8f => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, shade_color, 0.0, 0.5, 1.0, 0.5) - } - 0x1fb90 => push_terminal_custom_glyph_rect(&mut rects, bounds, shade_color), - 0x1fb91 => { - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.0, 0.0, 1.0, 0.5); - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - shade_color, - 0.0, - 0.5, - 1.0, - 0.5, - ); - } - 0x1fb92 => { - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - shade_color, - 0.0, - 0.0, - 1.0, - 0.5, - ); - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.0, 0.5, 1.0, 0.5); - } - 0x1fb94 => { - push_terminal_custom_glyph_fraction( - &mut rects, - bounds, - shade_color, - 0.0, - 0.0, - 0.5, - 1.0, - ); - push_terminal_custom_glyph_fraction(&mut rects, bounds, color, 0.5, 0.0, 0.5, 1.0); - } - _ => return None, - } - - Some(rects) -} - -fn terminal_braille_rects( - character: char, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let pattern = (character as u32).checked_sub(0x2800)?; - if pattern > 0xff { - return None; - } - - let mut rects = Vec::new(); - let dot_positions = [ - (1.0, 0.0), - (1.0, 2.0), - (1.0, 4.0), - (5.0, 0.0), - (5.0, 2.0), - (5.0, 4.0), - (1.0, 6.0), - (5.0, 6.0), - ]; - let x_eighth = bounds.size.width / 8.0; - let vertical_padding = bounds.size.height * 0.1; - let y_eighth = (bounds.size.height - vertical_padding * 2.0) / 8.0; - let radius = x_eighth.min(y_eighth); - - for (bit, (x, y)) in dot_positions.into_iter().enumerate() { - if pattern & (1 << bit) == 0 { - continue; - } - let center_x = bounds.left() + x_eighth * (x + 1.0); - let center_y = bounds.top() + vertical_padding + y_eighth * (y + 1.0); - push_terminal_custom_glyph_rect( - &mut rects, - snapped_pixel_bounds( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - color, - ); - } - - Some(rects) -} - -fn terminal_box_drawing_rects( - character: char, - bounds: Bounds, - color: gpui::Hsla, -) -> Option> { - let glyph = terminal_box_drawing_glyph(character)?; - let mut rects = Vec::new(); - if let Some(weight) = glyph.up { - push_terminal_box_vertical_segment(&mut rects, bounds, color, weight, 0.0, 0.5); - } - if let Some(weight) = glyph.right { - push_terminal_box_horizontal_segment(&mut rects, bounds, color, weight, 0.5, 1.0); - } - if let Some(weight) = glyph.down { - push_terminal_box_vertical_segment(&mut rects, bounds, color, weight, 0.5, 1.0); - } - if let Some(weight) = glyph.left { - push_terminal_box_horizontal_segment(&mut rects, bounds, color, weight, 0.0, 0.5); - } - - Some(rects) -} - -fn terminal_box_drawing_glyph(character: char) -> Option { - let light = TerminalLineWeight::Light; - let heavy = TerminalLineWeight::Heavy; - let glyph = match character { - '─' => TerminalBoxDrawingGlyph::horizontal(light), - '━' => TerminalBoxDrawingGlyph::horizontal(heavy), - '│' => TerminalBoxDrawingGlyph::vertical(light), - '┃' => TerminalBoxDrawingGlyph::vertical(heavy), - '┌' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(light), None), - '┍' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(light), None), - '┎' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(heavy), None), - '┏' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(heavy), None), - '┐' => TerminalBoxDrawingGlyph::new(None, None, Some(light), Some(light)), - '┑' => TerminalBoxDrawingGlyph::new(None, None, Some(light), Some(heavy)), - '┒' => TerminalBoxDrawingGlyph::new(None, None, Some(heavy), Some(light)), - '┓' => TerminalBoxDrawingGlyph::new(None, None, Some(heavy), Some(heavy)), - '└' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), None, None), - '┕' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), None, None), - '┖' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), None, None), - '┗' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), None, None), - '┘' => TerminalBoxDrawingGlyph::new(Some(light), None, None, Some(light)), - '┙' => TerminalBoxDrawingGlyph::new(Some(light), None, None, Some(heavy)), - '┚' => TerminalBoxDrawingGlyph::new(Some(heavy), None, None, Some(light)), - '┛' => TerminalBoxDrawingGlyph::new(Some(heavy), None, None, Some(heavy)), - '├' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(light), None), - '┝' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(light), None), - '┞' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(light), None), - '┟' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(heavy), None), - '┠' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(heavy), None), - '┡' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(light), None), - '┢' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(heavy), None), - '┣' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(heavy), None), - '┤' => TerminalBoxDrawingGlyph::new(Some(light), None, Some(light), Some(light)), - '┥' => TerminalBoxDrawingGlyph::new(Some(light), None, Some(light), Some(heavy)), - '┦' => TerminalBoxDrawingGlyph::new(Some(heavy), None, Some(light), Some(light)), - '┧' => TerminalBoxDrawingGlyph::new(Some(light), None, Some(heavy), Some(light)), - '┨' => TerminalBoxDrawingGlyph::new(Some(heavy), None, Some(heavy), Some(light)), - '┩' => TerminalBoxDrawingGlyph::new(Some(heavy), None, Some(light), Some(heavy)), - '┪' => TerminalBoxDrawingGlyph::new(Some(light), None, Some(heavy), Some(heavy)), - '┫' => TerminalBoxDrawingGlyph::new(Some(heavy), None, Some(heavy), Some(heavy)), - '┬' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(light), Some(light)), - '┭' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(light), Some(heavy)), - '┮' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(light), Some(light)), - '┯' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(light), Some(heavy)), - '┰' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(heavy), Some(light)), - '┱' => TerminalBoxDrawingGlyph::new(None, Some(light), Some(heavy), Some(heavy)), - '┲' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(heavy), Some(light)), - '┳' => TerminalBoxDrawingGlyph::new(None, Some(heavy), Some(heavy), Some(heavy)), - '┴' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), None, Some(light)), - '┵' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), None, Some(heavy)), - '┶' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), None, Some(light)), - '┷' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), None, Some(heavy)), - '┸' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), None, Some(light)), - '┹' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), None, Some(heavy)), - '┺' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), None, Some(light)), - '┻' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), None, Some(heavy)), - '┼' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(light), Some(light)), - '┽' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(light), Some(heavy)), - '┾' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(light), Some(light)), - '┿' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(light), Some(heavy)), - '╀' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(light), Some(light)), - '╁' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(heavy), Some(light)), - '╂' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(heavy), Some(light)), - '╃' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(light), Some(heavy)), - '╄' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(light), Some(light)), - '╅' => TerminalBoxDrawingGlyph::new(Some(light), Some(light), Some(heavy), Some(heavy)), - '╆' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(heavy), Some(light)), - '╇' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(light), Some(heavy)), - '╈' => TerminalBoxDrawingGlyph::new(Some(light), Some(heavy), Some(heavy), Some(heavy)), - '╉' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(light), Some(heavy), Some(heavy)), - '╊' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(heavy), Some(light)), - '╋' => TerminalBoxDrawingGlyph::new(Some(heavy), Some(heavy), Some(heavy), Some(heavy)), - '╴' => TerminalBoxDrawingGlyph::new(None, None, None, Some(light)), - '╵' => TerminalBoxDrawingGlyph::new(Some(light), None, None, None), - '╶' => TerminalBoxDrawingGlyph::new(None, Some(light), None, None), - '╷' => TerminalBoxDrawingGlyph::new(None, None, Some(light), None), - '╸' => TerminalBoxDrawingGlyph::new(None, None, None, Some(heavy)), - '╹' => TerminalBoxDrawingGlyph::new(Some(heavy), None, None, None), - '╺' => TerminalBoxDrawingGlyph::new(None, Some(heavy), None, None), - '╻' => TerminalBoxDrawingGlyph::new(None, None, Some(heavy), None), - '╼' => TerminalBoxDrawingGlyph::new(None, Some(heavy), None, Some(light)), - '╽' => TerminalBoxDrawingGlyph::new(Some(light), None, Some(heavy), None), - '╾' => TerminalBoxDrawingGlyph::new(None, Some(light), None, Some(heavy)), - '╿' => TerminalBoxDrawingGlyph::new(Some(heavy), None, Some(light), None), - _ => return None, - }; - - Some(glyph) -} - -impl TerminalBoxDrawingGlyph { - fn new( - up: Option, - right: Option, - down: Option, - left: Option, - ) -> Self { - Self { - up, - right, - down, - left, - } - } - - fn horizontal(weight: TerminalLineWeight) -> Self { - Self::new(None, Some(weight), None, Some(weight)) - } - - fn vertical(weight: TerminalLineWeight) -> Self { - Self::new(Some(weight), None, Some(weight), None) - } -} - -fn push_terminal_box_horizontal_segment( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, - weight: TerminalLineWeight, - left: f32, - right: f32, -) { - let thickness = terminal_box_line_thickness(bounds, weight); - let y_center = bounds.top() + bounds.size.height / 2.0; - let segment_left = bounds.left() + bounds.size.width * left; - let segment_right = bounds.left() + bounds.size.width * right; - push_terminal_custom_glyph_rect( - rects, - snapped_pixel_bounds( - segment_left, - y_center - thickness / 2.0, - segment_right, - y_center + thickness / 2.0, - ), - color, - ); -} - -fn push_terminal_box_vertical_segment( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, - weight: TerminalLineWeight, - top: f32, - bottom: f32, -) { - let thickness = terminal_box_line_thickness(bounds, weight); - let x_center = bounds.left() + bounds.size.width / 2.0; - let segment_top = bounds.top() + bounds.size.height * top; - let segment_bottom = bounds.top() + bounds.size.height * bottom; - push_terminal_custom_glyph_rect( - rects, - snapped_pixel_bounds( - x_center - thickness / 2.0, - segment_top, - x_center + thickness / 2.0, - segment_bottom, - ), - color, - ); -} - -fn terminal_box_line_thickness(bounds: Bounds, weight: TerminalLineWeight) -> Pixels { - let light = px((f32::from(bounds.size.width) / 5.0).floor().max(1.0)); - match weight { - TerminalLineWeight::Light => light, - TerminalLineWeight::Heavy => (light + px(2.0)).min(bounds.size.width), - } -} - -fn push_terminal_quadrant_glyph( - rects: &mut Vec, - codepoint: u32, - bounds: Bounds, - color: gpui::Hsla, -) { - let quadrant_mask = match codepoint { - 0x2596 => 0b0100, - 0x2597 => 0b1000, - 0x2598 => 0b0001, - 0x2599 => 0b1101, - 0x259a => 0b1001, - 0x259b => 0b0111, - 0x259c => 0b1011, - 0x259d => 0b0010, - 0x259e => 0b0110, - 0x259f => 0b1110, - _ => 0, - }; - - if quadrant_mask & 0b0001 != 0 { - push_terminal_custom_glyph_fraction(rects, bounds, color, 0.0, 0.0, 0.5, 0.5); - } - if quadrant_mask & 0b0010 != 0 { - push_terminal_custom_glyph_fraction(rects, bounds, color, 0.5, 0.0, 0.5, 0.5); - } - if quadrant_mask & 0b0100 != 0 { - push_terminal_custom_glyph_fraction(rects, bounds, color, 0.0, 0.5, 0.5, 0.5); - } - if quadrant_mask & 0b1000 != 0 { - push_terminal_custom_glyph_fraction(rects, bounds, color, 0.5, 0.5, 0.5, 0.5); - } -} - -fn push_terminal_octant_glyph( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, - left: f32, - top: f32, - width: f32, - height: f32, -) { - push_terminal_custom_glyph_fraction( - rects, - bounds, - color, - left / 8.0, - top / 8.0, - width / 8.0, - height / 8.0, - ); -} - -fn push_terminal_checker_glyph( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, - inverse: bool, -) { - for row in 0..4 { - for column in 0..4 { - if (row + column) % 2 == usize::from(inverse) { - push_terminal_octant_glyph( - rects, - bounds, - color, - column as f32 * 2.0, - row as f32 * 2.0, - 2.0, - 2.0, - ); - } - } - } -} - -fn push_terminal_custom_glyph_rect( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, -) { - if bounds.size.width > Pixels::ZERO && bounds.size.height > Pixels::ZERO { - rects.push(TerminalPaintCustomGlyphRect { bounds, color }); - } -} - -fn push_terminal_custom_glyph_fraction( - rects: &mut Vec, - bounds: Bounds, - color: gpui::Hsla, - left: f32, - top: f32, - width: f32, - height: f32, -) { - let bounds = terminal_fraction_bounds(bounds, left, top, width, height); - push_terminal_custom_glyph_rect(rects, bounds, color); -} - -fn terminal_fraction_bounds( - bounds: Bounds, - left: f32, - top: f32, - width: f32, - height: f32, -) -> Bounds { - let bounds_left = f32::from(bounds.left()); - let bounds_top = f32::from(bounds.top()); - let bounds_width = f32::from(bounds.size.width); - let bounds_height = f32::from(bounds.size.height); - let rect_left = bounds_left + bounds_width * left; - let rect_top = bounds_top + bounds_height * top; - let rect_right = rect_left + bounds_width * width; - let rect_bottom = rect_top + bounds_height * height; - - snapped_pixel_bounds(px(rect_left), px(rect_top), px(rect_right), px(rect_bottom)) -} - -fn snapped_pixel_bounds( - left: Pixels, - top: Pixels, - right: Pixels, - bottom: Pixels, -) -> Bounds { - let left = f32::from(left).floor(); - let top = f32::from(top).floor(); - let right = f32::from(right).ceil(); - let bottom = f32::from(bottom).ceil(); - - Bounds::new( - point(px(left), px(top)), - gpui::size(px((right - left).max(0.0)), px((bottom - top).max(0.0))), - ) -} - -fn terminal_style_foreground(style: TerminalStyle) -> gpui::Hsla { - let mut color = gpui::Hsla::from(rgba_for_terminal_color(style.foreground)); - if style.dim { - color = color.opacity(0.65); - } - color -} - -fn terminal_text_run( - len: usize, - style: TerminalStyle, - metrics: TerminalMetrics, - underline_thickness: Pixels, -) -> TextRun { - let foreground = rgba_for_terminal_color(style.foreground); - let color = terminal_style_foreground(style); - TextRun { - len, - font: terminal_font_for_style(metrics.font_family, style), - color, - background_color: None, - underline: style.underline.then_some(UnderlineStyle { - thickness: underline_thickness, - color: Some(gpui::Hsla::from(foreground)), - wavy: false, - }), - strikethrough: style.strikeout.then_some(StrikethroughStyle { - thickness: underline_thickness, - color: Some(gpui::Hsla::from(foreground)), - }), - } -} - -fn terminal_font_for_style(font_family: &str, style: TerminalStyle) -> gpui::Font { - let mut font = terminal_font(font_family); - if style.bold { - font = font.bold(); - } - if style.italic { - font = font.italic(); - } - font -} - -fn terminal_background_rects( - snapshot: &TerminalSnapshot, - screen_background: TerminalColor, -) -> Vec { - let mut rects: Vec = Vec::new(); - for (row_index, row) in snapshot.rows.iter().enumerate() { - let mut row_rects: Vec = Vec::new(); - for run in &row.cell_runs { - if run.style.background == screen_background { - continue; - } - if let Some(last) = row_rects.last_mut() - && last.color == run.style.background - && last.column + last.width == run.column - { - last.width += run.width; - continue; - } - - row_rects.push(TerminalBackgroundRect { - row: row_index, - rows: 1, - column: run.column, - width: run.width, - color: run.style.background, - }); - } - for rect in row_rects { - push_terminal_background_rect(&mut rects, rect); - } - } - rects -} - -fn push_terminal_background_rect( - rects: &mut Vec, - rect: TerminalBackgroundRect, -) { - if let Some(last) = rects.last_mut() - && last.color == rect.color - && last.column == rect.column - && last.width == rect.width - && last.row + last.rows == rect.row - { - last.rows += rect.rows; - return; - } - - rects.push(rect); -} - -fn terminal_selection_rects( - ranges: &[TerminalSelectionRange], - grid: TerminalGridPixels, - metrics: TerminalMetrics, -) -> Vec { - ranges - .iter() - .filter(|range| range.width > 0) - .map(|range| TerminalPaintCustomGlyphRect { - bounds: terminal_cell_bounds(range.row, range.column, 1, range.width, grid), - color: metrics.selection_color, - }) - .collect() -} - -fn terminal_grid_position_for_point( - point: Point, - bounds: Option>, - metrics: TerminalMetrics, - window: &mut Window, - columns: usize, - rows: usize, -) -> Option<(usize, usize)> { - if columns == 0 || rows == 0 { - return None; - } - let grid = terminal_grid_pixels(bounds?, metrics, window); - if grid.cell_width <= Pixels::ZERO || grid.row_height <= Pixels::ZERO { - return None; - } - - let relative_x = f32::from(point.x - grid.origin.x); - let relative_y = f32::from(point.y - grid.origin.y); - let column = terminal_grid_axis_index(relative_x, f32::from(grid.cell_width), columns); - let row = terminal_grid_axis_index(relative_y, f32::from(grid.row_height), rows); - Some((row, column)) -} - -fn terminal_grid_axis_index(offset: f32, cell_size: f32, limit: usize) -> usize { - if offset <= 0.0 { - return 0; - } - ((offset / cell_size).floor() as usize).min(limit.saturating_sub(1)) -} - -fn write_terminal_key( - session: &Entity, - input: TerminalKeyInput, - cx: &mut Context, -) -> bool { - let handled = session.update(cx, |session, cx| { - let handled = session.write_key(input); - if handled { - cx.notify(); - } - handled - }); - if handled { - cx.stop_propagation(); - } - handled -} - -fn terminal_mouse_button(button: MouseButton) -> Option { - match button { - MouseButton::Left => Some(TerminalMouseButton::Left), - MouseButton::Middle => Some(TerminalMouseButton::Middle), - MouseButton::Right => Some(TerminalMouseButton::Right), - MouseButton::Navigate(_) => None, - } -} - -fn terminal_mouse_modifiers(modifiers: gpui::Modifiers) -> TerminalMouseModifiers { - TerminalMouseModifiers { - shift: modifiers.shift, - alt: modifiers.alt, - control: modifiers.control, - } -} - -fn is_terminal_copy(event: &KeyDownEvent) -> bool { - event.keystroke.key == "c" - && event.keystroke.modifiers.control - && event.keystroke.modifiers.shift -} - -fn is_terminal_paste(event: &KeyDownEvent) -> bool { - event.keystroke.key == "v" - && event.keystroke.modifiers.control - && event.keystroke.modifiers.shift -} - -fn terminal_key_input(event: &KeyDownEvent) -> TerminalKeyInput { - TerminalKeyInput { - key: event.keystroke.key.clone(), - text: terminal_key_text(event), - control: event.keystroke.modifiers.control, - alt: event.keystroke.modifiers.alt, - shift: event.keystroke.modifiers.shift, - platform: event.keystroke.modifiers.platform, - } -} - -fn terminal_key_text(event: &KeyDownEvent) -> Option { - if event.keystroke.modifiers.control - || event.keystroke.modifiers.alt - || event.keystroke.modifiers.platform - { - return event.keystroke.key_char.clone(); - } - - None -} - -fn resize_terminal_to_bounds( - session: &Entity, - bounds: Bounds, - metrics: TerminalMetrics, - window: &mut Window, - cx: &mut App, -) { - let cell_width = terminal_cell_width_pixels(metrics, window); - let row_height = terminal_row_height_pixels(metrics, window); - if cell_width <= Pixels::ZERO || row_height <= Pixels::ZERO { - return; - } - let columns = ((bounds.size.width / cell_width).floor() as usize).max(2); - let rows = ((bounds.size.height / row_height).floor() as usize).max(1); - let cell_width_px = f32::from(cell_width).round().max(1.0).min(u16::MAX as f32) as u16; - let cell_height_px = f32::from(row_height).round().max(1.0).min(u16::MAX as f32) as u16; - session.update(cx, |session, _| { - session.resize(columns, rows, cell_width_px, cell_height_px) - }); -} - -fn rgba_for_terminal_color(color: TerminalColor) -> gpui::Rgba { - rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32) -} - -fn render_cursor( - cursor: terminal::TerminalCursor, - metrics: TerminalMetrics, - is_focused: bool, -) -> AnyElement { - canvas( - move |bounds, window, _cx| { - if !is_focused || cursor.shape == TerminalCursorShape::Hidden { - return None; - } - - let grid = terminal_grid_pixels(bounds, metrics, window); - Some(terminal_paint_cursor(cursor, grid)) - }, - move |_bounds, paint_cursor, window, _cx| { - let Some(paint_cursor) = paint_cursor else { - return; - }; - - let color = - gpui::Hsla::from(rgba_for_terminal_color(metrics.cursor_color)).opacity(0.85); - match paint_cursor.shape { - TerminalCursorShape::Block => { - window.paint_quad(fill(paint_cursor.bounds, color)); - } - TerminalCursorShape::HollowBlock => { - window.paint_quad(outline( - paint_cursor.bounds, - color, - gpui::BorderStyle::default(), - )); - } - TerminalCursorShape::Underline | TerminalCursorShape::Beam => { - window.paint_quad(fill(paint_cursor.bounds, color)); - } - TerminalCursorShape::Hidden => {} - } - }, - ) - .absolute() - .top_0() - .right_0() - .bottom_0() - .left_0() - .into_any_element() -} - -fn terminal_paint_cursor( - cursor: terminal::TerminalCursor, - grid: TerminalGridPixels, -) -> TerminalPaintCursor { - let cell_bounds = terminal_cell_bounds(cursor.row, cursor.column, 1, 1, grid); - let bounds = match cursor.shape { - TerminalCursorShape::Underline => Bounds::new( - point( - cell_bounds.left(), - cell_bounds.bottom() - grid.cursor_thickness, - ), - gpui::size(cell_bounds.size.width, grid.cursor_thickness), - ), - TerminalCursorShape::Beam => Bounds::new( - cell_bounds.origin, - gpui::size(grid.cursor_thickness, cell_bounds.size.height), - ), - TerminalCursorShape::Block - | TerminalCursorShape::HollowBlock - | TerminalCursorShape::Hidden => cell_bounds, - }; - - TerminalPaintCursor { - bounds, - shape: cursor.shape, - } -} - -fn terminal_cell_bounds( - row: usize, - column: usize, - rows: usize, - columns: usize, - grid: TerminalGridPixels, -) -> Bounds { - let left = f32::from(grid.origin.x + grid.cell_width * column).floor(); - let top = f32::from(grid.origin.y + grid.row_height * row).floor(); - let right = f32::from(grid.origin.x + grid.cell_width * (column + columns)).ceil(); - let bottom = f32::from(grid.origin.y + grid.row_height * (row + rows)).ceil(); - - Bounds::new( - point(px(left), px(top)), - gpui::size(px((right - left).max(0.0)), px((bottom - top).max(0.0))), - ) -} - -fn render_bottom_bar( - session: &Entity, - snapshot: &TerminalSnapshot, - cx: &mut Context, -) -> AnyElement { - let theme = *cx.theme(); - let key = session.read(cx).key(); - let id_prefix = format!("terminal-{}-{}", key.workspace_id, key.tab_id); - let zoom_out = session.clone(); - let zoom_in = session.clone(); - let reset_zoom = session.clone(); - let reload = session.clone(); - - div() - .h(rems(BOTTOM_BAR_HEIGHT_REM)) - .flex_none() - .w_full() - .flex() - .items_center() - .justify_between() - .px_2() - .bg(theme.bg_surface) - .border_t_1() - .border_color(theme.border_subtle) - .text_xs() - .text_color(theme.text_muted) - .child(render_status(snapshot)) - .child( - div() - .flex() - .items_center() - .gap_1() - .child(compact_text_button( - format!("{id_prefix}-zoom-out"), - "-", - move |_, _, cx| { - zoom_out.update(cx, |session, cx| session.zoom_out(cx)); - }, - )) - .child( - Button::new(SharedString::from(format!("{id_prefix}-zoom-reset"))) - .ghost() - .tab_stop(false) - .min_w(rems(2.5)) - .h(rems(BOTTOM_BAR_BUTTON_SIZE_REM)) - .label(format!("{}", snapshot.zoom_percent)) - .on_click(move |_, _, cx| { - reset_zoom.update(cx, |session, cx| session.reset_zoom(cx)); - }), - ) - .child(compact_text_button( - format!("{id_prefix}-zoom-in"), - "+", - move |_, _, cx| { - zoom_in.update(cx, |session, cx| session.zoom_in(cx)); - }, - )) - .child(render_separator(theme)) - .child(render_shell_picker( - key, - &snapshot.selected_shell_label, - &snapshot.shells, - session, - )) - .child(render_separator(theme)) - .child(icon_button( - format!("{id_prefix}-reload"), - IconName::Refresh, - move |_, _, cx| { - reload.update(cx, |session, cx| session.reload(cx)); - }, - )), - ) - .into_any_element() -} - -fn render_status(snapshot: &TerminalSnapshot) -> AnyElement { - let text = match &snapshot.status { - TerminalStatus::Running => snapshot.title.as_deref().unwrap_or("Terminal").to_string(), - TerminalStatus::Restarting => "Restarting".to_string(), - TerminalStatus::Failed(err) => err.clone(), - TerminalStatus::Exited => "Exited".to_string(), - }; - div() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(text) - .into_any_element() -} - -fn render_shell_picker( - key: TerminalKey, - selected_label: &str, - shells: &[ShellProfile], - session: &Entity, -) -> AnyElement { - let selected_label = selected_label.to_string(); - let options = shells - .iter() - .map(|shell| (shell.id.clone(), shell.label.clone())) - .collect::>(); - let session = session.clone(); - - Button::new(SharedString::from(format!( - "terminal-shell-picker-{}-{}", - key.workspace_id, key.tab_id - ))) - .ghost() - .tab_stop(false) - .w(rems(SHELL_PICKER_WIDTH_REM)) - .h(rems(BOTTOM_BAR_BUTTON_SIZE_REM)) - .label(selected_label.clone()) - .dropdown_caret(true) - .dropdown_menu_with_anchor(Anchor::BottomRight, move |menu, window, _| { - let menu_width = rems(SHELL_PICKER_WIDTH_REM).to_pixels(window.rem_size()); - options - .iter() - .fold(menu.min_w(menu_width), |menu, (shell_id, label)| { - let shell_id = shell_id.clone(); - let session = session.clone(); - menu.item( - PopupMenuItem::new(label.clone()) - .checked(label == &selected_label) - .on_click(move |_, _, cx| { - session.update(cx, |session, cx| session.select_shell(&shell_id, cx)); - }), - ) - }) - }) - .into_any_element() -} - -fn compact_text_button( - id: impl Into, - label: &'static str, - on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, -) -> impl IntoElement { - Button::new(id.into()) - .ghost() - .tab_stop(false) - .size(rems(BOTTOM_BAR_BUTTON_SIZE_REM)) - .label(label) - .on_click(on_click) -} - -fn icon_button( - id: impl Into, - icon_name: IconName, - on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, -) -> impl IntoElement { - Button::new(id.into()) - .ghost() - .tab_stop(false) - .size(rems(BOTTOM_BAR_BUTTON_SIZE_REM)) - .child(component_icon(icon_name).small()) - .on_click(on_click) -} - -fn component_icon(icon: IconName) -> ComponentIcon { - ComponentIcon::empty().path(icon.path()) -} - -fn render_separator(theme: Theme) -> AnyElement { - Separator::vertical() - .h(rems(1.0)) - .color(gpui::Hsla::from(theme.terminal_separator)) - .into_any_element() -} - -struct TerminalInputElement { - session: Entity, -} - -impl IntoElement for TerminalInputElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for TerminalInputElement { - type RequestLayoutState = (); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - style.size.width = relative(1.0).into(); - style.size.height = relative(1.0).into(); - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - _bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - _window: &mut Window, - _cx: &mut App, - ) -> Self::PrepaintState { - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - _prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let focus_handle = self.session.read(cx).focus_handle(); - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.session.clone()), - cx, - ); - } -} diff --git a/crates/ui/tests/tsx_highlighter.rs b/crates/ui/tests/tsx_highlighter.rs deleted file mode 100644 index 9584ef94..00000000 --- a/crates/ui/tests/tsx_highlighter.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::ops::Range; - -use gpui::HighlightStyle; -use gpui_component::{ - highlighter::{HighlightTheme, SyntaxHighlighter}, - input::Rope, -}; - -fn has_styled_token(styles: &[(Range, HighlightStyle)], source: &str, token: &str) -> bool { - let start = source.find(token).expect("token should exist in source"); - let end = start + token.len(); - - styles - .iter() - .any(|(range, style)| range.start <= start && range.end >= end && style.color.is_some()) -} - -fn style_for_token( - styles: &[(Range, HighlightStyle)], - source: &str, - token: &str, -) -> HighlightStyle { - let start = source.find(token).expect("token should exist in source"); - let end = start + token.len(); - - styles - .iter() - .find(|(range, style)| range.start <= start && range.end >= end && style.color.is_some()) - .map(|(_, style)| *style) - .unwrap_or_else(|| panic!("token {token:?} should have a colored style")) -} - -#[test] -fn tsx_highlighter_uses_full_typescript_and_jsx_queries() { - let source = r#"import { useState } from "react"; - -export default function Home() { - const [isReady, setReady] = useState(false); - return
Ready
; -} -"#; - - let rope = Rope::from_str(source); - let mut highlighter = SyntaxHighlighter::new("tsx"); - assert_eq!(highlighter.language().as_ref(), "tsx"); - - assert!(highlighter.update(None, &rope, None)); - let styles = highlighter.styles(&(0..source.len()), &HighlightTheme::default_dark()); - - assert!(has_styled_token(&styles, source, "import")); - assert!(has_styled_token(&styles, source, "react")); - assert!(has_styled_token(&styles, source, "className")); - assert!(has_styled_token(&styles, source, "section")); -} - -#[test] -fn tsx_highlighter_styles_custom_components_as_jsx_tags() { - let source = r#"export function Balance({ kycRequired }) { - return ( - - ); -} -"#; - - let rope = Rope::from_str(source); - let mut highlighter = SyntaxHighlighter::new("tsx"); - - assert!(highlighter.update(None, &rope, None)); - let styles = highlighter.styles(&(0..source.len()), &HighlightTheme::default_dark()); - let tag_style = style_for_token(&styles, source, "span"); - - assert_eq!(style_for_token(&styles, source, "Button"), tag_style); - assert_eq!(style_for_token(&styles, source, "TriangleAlert"), tag_style); - assert_eq!(style_for_token(&styles, source, "Plus"), tag_style); -} diff --git a/crates/ui/tests/xml_highlighter.rs b/crates/ui/tests/xml_highlighter.rs deleted file mode 100644 index 1e9320ef..00000000 --- a/crates/ui/tests/xml_highlighter.rs +++ /dev/null @@ -1,45 +0,0 @@ -use gpui::HighlightStyle; -use gpui_component::{ - highlighter::{HighlightTheme, SyntaxHighlighter}, - input::Rope, -}; - -fn has_colored_token( - styles: &[(std::ops::Range, HighlightStyle)], - source: &str, - token: &str, -) -> bool { - let start = source.find(token).expect("token should exist in source"); - let end = start + token.len(); - - styles - .iter() - .any(|(range, style)| range.start <= start && range.end >= end && style.color.is_some()) -} - -#[test] -fn xml_highlighter_styles_tags_and_attributes() { - let source = r#" - - Tove - -"#; - - let rope = Rope::from_str(source); - let mut highlighter = SyntaxHighlighter::new("xml"); - assert_eq!(highlighter.language().as_ref(), "xml"); - - assert!(highlighter.update(None, &rope, None)); - let styles = highlighter.styles(&(0..source.len()), &HighlightTheme::default_dark()); - - assert!(has_colored_token(&styles, source, "note")); - assert!(has_colored_token(&styles, source, "priority")); - assert!(has_colored_token(&styles, source, "high")); -} - -#[test] -fn svg_alias_uses_xml_highlighter() { - let highlighter = SyntaxHighlighter::new("svg"); - - assert_eq!(highlighter.language().as_ref(), "xml"); -} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml deleted file mode 100644 index 13871ac7..00000000 --- a/crates/workspace/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "workspace" -version = "0.4.9" -edition = "2024" - -[dependencies] -pane_tree = { path = "../pane_tree" } diff --git a/crates/workspace/README.md b/crates/workspace/README.md deleted file mode 100644 index 9c6b8b7e..00000000 --- a/crates/workspace/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# workspace - -Role: workspace collection model. - -Owns: -- Workspace identity, path, display name, and pane tree. -- Workspace manager state. -- Active and previous-active workspace tracking. -- Workspace add, close, select, and reorder behavior. - -Does Not Own: -- Workspace UI rendering. -- File tree root synchronization. -- Persistence. -- File-system watching. diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs deleted file mode 100644 index f885d70d..00000000 --- a/crates/workspace/src/lib.rs +++ /dev/null @@ -1,203 +0,0 @@ -use std::path::PathBuf; - -use pane_tree::PaneTree; - -pub struct Workspace { - pub id: usize, - pub path: PathBuf, - pub name: String, - pub pane_tree: PaneTree, - pub file_tree_expanded_dirs: Vec, -} - -impl Workspace { - pub fn initial(&self) -> String { - self.name - .chars() - .next() - .map_or_else(|| "?".to_string(), |c| c.to_ascii_uppercase().to_string()) - } -} - -pub struct WorkspaceManager { - workspaces: Vec, - active: Option, - previous_active: Option, - next_id: usize, -} - -impl Default for WorkspaceManager { - fn default() -> Self { - Self::new() - } -} - -impl WorkspaceManager { - pub fn new() -> Self { - Self { - workspaces: Vec::new(), - active: None, - previous_active: None, - next_id: 0, - } - } - - pub fn from_parts(workspaces: Vec, active: Option, next_id: usize) -> Self { - Self { - workspaces, - active, - previous_active: active, - next_id, - } - } - - pub fn workspaces(&self) -> &[Workspace] { - &self.workspaces - } - - pub fn active_id(&self) -> Option { - self.active - } - - pub fn previous_active_id(&self) -> Option { - self.previous_active - } - - pub fn next_id(&self) -> usize { - self.next_id - } - - pub fn active_workspace(&self) -> Option<&Workspace> { - let id = self.active?; - self.workspace(id) - } - - pub fn workspace(&self, id: usize) -> Option<&Workspace> { - self.workspaces.iter().find(|w| w.id == id) - } - - pub fn workspace_mut(&mut self, id: usize) -> Option<&mut Workspace> { - self.workspaces.iter_mut().find(|w| w.id == id) - } - - pub fn add(&mut self, path: PathBuf) -> usize { - if let Some(existing) = self.workspaces.iter().find(|w| w.path == path) { - let id = existing.id; - self.set_active(Some(id)); - return id; - } - let id = self.next_id; - self.next_id += 1; - let name = path - .file_name() - .and_then(|os| os.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| path.display().to_string()); - self.workspaces.push(Workspace { - id, - path, - name, - pane_tree: PaneTree::new(), - file_tree_expanded_dirs: Vec::new(), - }); - self.set_active(Some(id)); - id - } - - fn set_active(&mut self, id: Option) { - if self.active != id { - self.previous_active = self.active; - self.active = id; - } - } - - pub fn active_pane_tree(&self) -> Option<&PaneTree> { - let id = self.active?; - self.pane_tree(id) - } - - pub fn active_pane_tree_mut(&mut self) -> Option<&mut PaneTree> { - let id = self.active?; - self.pane_tree_mut(id) - } - - pub fn pane_tree(&self, id: usize) -> Option<&PaneTree> { - self.workspaces - .iter() - .find(|w| w.id == id) - .map(|w| &w.pane_tree) - } - - pub fn pane_tree_mut(&mut self, id: usize) -> Option<&mut PaneTree> { - self.workspaces - .iter_mut() - .find(|w| w.id == id) - .map(|w| &mut w.pane_tree) - } - - pub fn close(&mut self, id: usize) -> bool { - let Some(pos) = self.workspaces.iter().position(|w| w.id == id) else { - return false; - }; - self.workspaces.remove(pos); - if self.active == Some(id) { - let next = self - .workspaces - .get(pos) - .or_else(|| pos.checked_sub(1).and_then(|p| self.workspaces.get(p))) - .map(|w| w.id); - self.set_active(next); - } - if self.previous_active == Some(id) { - self.previous_active = self.active; - } - true - } - - pub fn select(&mut self, id: usize) -> bool { - if self.active == Some(id) { - return false; - } - if !self.workspaces.iter().any(|w| w.id == id) { - return false; - } - self.set_active(Some(id)); - true - } - - pub fn reorder_before(&mut self, drag_id: usize, target_id: usize) -> bool { - if drag_id == target_id { - return false; - } - let Some(from) = self.workspaces.iter().position(|w| w.id == drag_id) else { - return false; - }; - let Some(target) = self.workspaces.iter().position(|w| w.id == target_id) else { - return false; - }; - let workspace = self.workspaces.remove(from); - let insert_at = self - .workspaces - .iter() - .position(|w| w.id == target_id) - .unwrap_or(target); - if insert_at == from { - self.workspaces.insert(from, workspace); - return false; - } - self.workspaces.insert(insert_at, workspace); - true - } - - pub fn move_to_end(&mut self, drag_id: usize) -> bool { - let Some(from) = self.workspaces.iter().position(|w| w.id == drag_id) else { - return false; - }; - if from + 1 == self.workspaces.len() { - return false; - } - let workspace = self.workspaces.remove(from); - self.workspaces.push(workspace); - true - } -} diff --git a/crates/zoom/Cargo.toml b/crates/zoom/Cargo.toml deleted file mode 100644 index 1972a9a6..00000000 --- a/crates/zoom/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "zoom" -version = "0.4.9" -edition = "2024" - -[dependencies] -gpui = { workspace = true } -settings = { path = "../settings" } diff --git a/crates/zoom/README.md b/crates/zoom/README.md deleted file mode 100644 index 8e8bcabf..00000000 --- a/crates/zoom/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# zoom - -Role: UI zoom behavior. - -Owns: -- Zoom setting id and supported zoom range. -- Reading and writing zoom percentage from settings. -- Applying zoom by changing the GPUI window rem size. -- Zoom-related GPUI actions and action wiring. - -Does Not Own: -- Settings registry UI. -- General theme colors. -- Component layout decisions. diff --git a/crates/zoom/src/lib.rs b/crates/zoom/src/lib.rs deleted file mode 100644 index d6623f16..00000000 --- a/crates/zoom/src/lib.rs +++ /dev/null @@ -1,65 +0,0 @@ -use gpui::{App, BorrowAppContext, Context, InteractiveElement, Pixels, Window, actions, px}; -use settings::{ActiveSettings, SettingValue, Settings}; - -pub const SETTING_ID: &str = "appearance.zoom"; - -const DEFAULT_PERCENT: i64 = 100; -const MIN_PERCENT: i64 = 75; -const MAX_PERCENT: i64 = 125; -const STEP_PERCENT: i64 = 5; -const BASE_REM_PX: f32 = 16.0; - -actions!(zoom, [ZoomIn, ZoomOut, ResetZoom]); - -/// Read the current zoom percentage from settings, clamped to the supported range. -pub fn current_percent(cx: &App) -> i64 { - cx.settings() - .get(SETTING_ID) - .and_then(SettingValue::as_int) - .unwrap_or(DEFAULT_PERCENT) - .clamp(MIN_PERCENT, MAX_PERCENT) -} - -/// Apply the current zoom setting to the window's rem size so the UI rescales. -pub fn apply(window: &mut Window, cx: &App) { - window.set_rem_size(rem_for(current_percent(cx))); -} - -fn rem_for(percent: i64) -> Pixels { - px(BASE_REM_PX * percent as f32 / 100.0) -} - -fn set_percent(cx: &mut App, percent: i64) { - let clamped = percent.clamp(MIN_PERCENT, MAX_PERCENT); - if clamped == current_percent(cx) { - return; - } - cx.update_global::(|settings, _| { - settings.set(SETTING_ID, SettingValue::Int(clamped)); - }); -} - -/// Extension trait: chain `.wire_zoom_actions(cx)` onto a focusable element to -/// register the zoom action handlers in one line. -pub trait WireZoomActions: Sized { - fn wire_zoom_actions(self, cx: &mut Context) -> Self; -} - -impl WireZoomActions for E { - fn wire_zoom_actions(self, cx: &mut Context) -> Self { - self.on_action(cx.listener(|_, _: &ZoomIn, _, cx| { - let next = current_percent(cx) + STEP_PERCENT; - set_percent(cx, next); - cx.notify(); - })) - .on_action(cx.listener(|_, _: &ZoomOut, _, cx| { - let next = current_percent(cx) - STEP_PERCENT; - set_percent(cx, next); - cx.notify(); - })) - .on_action(cx.listener(|_, _: &ResetZoom, _, cx| { - set_percent(cx, DEFAULT_PERCENT); - cx.notify(); - })) - } -} diff --git a/packaging/linux/Kosmos.desktop.in b/packaging/linux/Kosmos.desktop.in deleted file mode 100644 index fa6e6778..00000000 --- a/packaging/linux/Kosmos.desktop.in +++ /dev/null @@ -1,14 +0,0 @@ -[Desktop Entry] -Version=1.0 -Type=Application -Name=$APP_NAME -GenericName=Code Editor -Comment=A highly customizable and versatile tab-based code editor. -TryExec=$APP_CLI -Exec=$APP_CLI $APP_ARGS -Icon=$APP_ICON -Terminal=false -Categories=Development;TextEditor;IDE; -Keywords=kosmos;editor;code; -StartupNotify=$DO_STARTUP_NOTIFY -StartupWMClass=$APP_ID diff --git a/packaging/linux/deb-control.in b/packaging/linux/deb-control.in deleted file mode 100644 index 837611b2..00000000 --- a/packaging/linux/deb-control.in +++ /dev/null @@ -1,10 +0,0 @@ -Package: kosmos -Version: ${KOSMOS_VERSION} -Section: editors -Priority: optional -Architecture: ${DEB_ARCH} -Maintainer: Martin Etchebarne -Homepage: https://github.com/etchebarne/kosmos -Depends: fonts-dejavu-core, libfontconfig1, libfreetype6, libxcb1, libxkbcommon0, libxkbcommon-x11-0, libwayland-client0, libwayland-cursor0, libvulkan1, vulkan-icd | mesa-vulkan-drivers -Description: A tab-based code editor. - A highly customizable and versatile tab-based code editor. diff --git a/packaging/linux/kosmos.spec.in b/packaging/linux/kosmos.spec.in deleted file mode 100644 index 8375a9b9..00000000 --- a/packaging/linux/kosmos.spec.in +++ /dev/null @@ -1,42 +0,0 @@ -Name: kosmos -Version: ${KOSMOS_VERSION} -Release: 1%{?dist} -Summary: A tab-based code editor. -License: MIT -URL: https://github.com/etchebarne/kosmos -BuildArch: ${RPM_ARCH} -Requires: fontconfig -Requires: freetype -Requires: dejavu-sans-mono-fonts -Requires: libxcb -Requires: libxkbcommon -Requires: libxkbcommon-x11 -Requires: wayland -Requires: vulkan-loader -Recommends: mesa-vulkan-drivers - -%global debug_package %{nil} - -%description -A highly customizable and versatile tab-based code editor. - -%install -rm -rf %{buildroot} -mkdir -p %{buildroot}/opt -cp -a "${KOSMOS_APP_DIR}" %{buildroot}/opt/Kosmos.app -mkdir -p %{buildroot}/usr/bin -ln -s /opt/Kosmos.app/bin/kosmos %{buildroot}/usr/bin/kosmos -mkdir -p %{buildroot}/usr/share/applications -cp -a "${KOSMOS_APP_DIR}/share/applications/net.etchebarne.Kosmos.desktop" %{buildroot}/usr/share/applications/net.etchebarne.Kosmos.desktop -mkdir -p %{buildroot}/usr/share/icons -cp -a "${KOSMOS_APP_DIR}/share/icons/hicolor" %{buildroot}/usr/share/icons/ -mkdir -p %{buildroot}/usr/share/licenses/kosmos -cp -a "${KOSMOS_APP_DIR}/LICENSE" %{buildroot}/usr/share/licenses/kosmos/LICENSE - -%files -/opt/Kosmos.app -/usr/bin/kosmos -/usr/share/applications/net.etchebarne.Kosmos.desktop -/usr/share/icons/hicolor/*/apps/kosmos.png -/usr/share/icons/hicolor/scalable/apps/kosmos.svg -%license /usr/share/licenses/kosmos/LICENSE diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh deleted file mode 100755 index a99f6763..00000000 --- a/scripts/bump-version.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -CURRENT=$(awk '/^\[workspace\.package\]/{f=1; next} f && /^version *= *"/{ match($0, /"([^"]+)"/, a); print a[1]; exit }' Cargo.toml) - -if [ -z "$CURRENT" ]; then - echo "Could not detect current version in Cargo.toml" >&2 - exit 1 -fi - -if [ $# -eq 0 ]; then - echo "Current version: $CURRENT" - echo "" - echo "Usage: $0 " - echo " $0 patch|minor|major" - echo "" - echo "Examples:" - echo " $0 0.2.0" - echo " $0 patch # $CURRENT -> $(echo "$CURRENT" | awk -F. '{print $1"."$2"."$3+1}')" - echo " $0 minor # $CURRENT -> $(echo "$CURRENT" | awk -F. '{print $1"."$2+1".0"}')" - echo " $0 major # $CURRENT -> $(echo "$CURRENT" | awk -F. '{print $1+1".0.0"}')" - exit 1 -fi - -NEW_VERSION="$1" - -case "$NEW_VERSION" in - patch) NEW_VERSION=$(echo "$CURRENT" | awk -F. '{print $1"."$2"."$3+1}') ;; - minor) NEW_VERSION=$(echo "$CURRENT" | awk -F. '{print $1"."$2+1".0"}') ;; - major) NEW_VERSION=$(echo "$CURRENT" | awk -F. '{print $1+1".0.0"}') ;; -esac - -if [ "$CURRENT" = "$NEW_VERSION" ]; then - echo "Version is already $CURRENT" - exit 1 -fi - -echo "Bumping version: $CURRENT -> $NEW_VERSION" -echo "" - -for cargo_toml in Cargo.toml crates/*/Cargo.toml; do - sed -i "s/^version = \"$CURRENT\"/version = \"$NEW_VERSION\"/" "$cargo_toml" - echo " updated $cargo_toml" -done - -for pkgbuild in aur/*/PKGBUILD; do - sed -i "s/^pkgver=$CURRENT/pkgver=$NEW_VERSION/" "$pkgbuild" - echo " updated $pkgbuild" -done - -if command -v cargo >/dev/null 2>&1; then - cargo update --workspace --offline >/dev/null 2>&1 || \ - cargo update --workspace >/dev/null 2>&1 || true - echo " updated Cargo.lock" -fi - -echo "" -echo "Done. Review with 'git diff', then commit and tag:" -echo " git commit -am \"Release v$NEW_VERSION\"" -echo " git tag v$NEW_VERSION" diff --git a/scripts/bundle-linux.sh b/scripts/bundle-linux.sh deleted file mode 100755 index 54d38ef2..00000000 --- a/scripts/bundle-linux.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# Build a relocatable Kosmos.app bundle and tar it for distribution. -# Output: target/release/kosmos-linux-.tar.gz -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" -ARCH="$(uname -m)" - -export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" - -cargo build --release --manifest-path "$ROOT/Cargo.toml" -p kosmos - -if command -v llvm-objcopy >/dev/null 2>&1; then - llvm-objcopy --strip-debug "$TARGET_DIR/release/kosmos" -elif command -v objcopy >/dev/null 2>&1; then - objcopy --strip-debug "$TARGET_DIR/release/kosmos" -fi - -TEMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TEMP_DIR"' EXIT -APP_DIR="$TEMP_DIR/Kosmos.app" - -mkdir -p "$APP_DIR/bin" "$APP_DIR/lib" \ - "$APP_DIR/share/applications" \ - "$APP_DIR/share/icons/hicolor/scalable/apps" -for size in 16 32 48 64 128 256 512; do - mkdir -p "$APP_DIR/share/icons/hicolor/${size}x${size}/apps" -done - -cp "$TARGET_DIR/release/kosmos" "$APP_DIR/bin/kosmos" - -# Bundle non-system .so dependencies. Skip core glibc / GPU / compositor libs -# that must come from the host (driver-specific or ABI-tied to the kernel). -SKIP_LIBS='(^|/)lib(c|stdc\+\+|gcc_s|m|pthread|dl|rt|resolv|nsl|util)\.so|(^|/)ld-linux.*\.so|(^|/)lib(GL|GLX|EGL|vulkan)\.so|(^|/)libwayland-.*\.so|(^|/)libxkbcommon.*\.so|(^|/)libX.*\.so|(^|/)libxcb.*\.so|(^|/)lib(drm|gbm|asound|bsd|md)\.so' -while read -r lib; do - [ -f "$lib" ] && cp -L "$lib" "$APP_DIR/lib/" -done < <(ldd "$APP_DIR/bin/kosmos" | awk '{print $3}' | grep -v '^$' | grep -v -E "$SKIP_LIBS" || true) - -for forbidden_lib in libc.so* libm.so* libpthread.so* libdl.so* librt.so* libresolv.so* libnsl.so* libutil.so* ld-linux*.so*; do - if compgen -G "$APP_DIR/lib/$forbidden_lib" >/dev/null; then - echo "Refusing to bundle host ABI library: $forbidden_lib" >&2 - exit 1 - fi -done - -# Icons -for size in 16 32 48 64 128 256 512; do - cp "$ROOT/assets/icon/icon-${size}.png" \ - "$APP_DIR/share/icons/hicolor/${size}x${size}/apps/kosmos.png" -done -cp "$ROOT/assets/icon/icon.svg" \ - "$APP_DIR/share/icons/hicolor/scalable/apps/kosmos.svg" - -# Desktop file: render with simple Icon=/Exec= names; the user installer -# rewrites these to absolute paths, the AUR package leaves them as hicolor -# theme lookups. -export APP_NAME="Kosmos" -export APP_CLI="kosmos" -export APP_ICON="kosmos" -export APP_ARGS="%U" -export APP_ID="net.etchebarne.Kosmos" -export DO_STARTUP_NOTIFY="true" -envsubst < "$ROOT/packaging/linux/Kosmos.desktop.in" \ - > "$APP_DIR/share/applications/${APP_ID}.desktop" - -cp "$ROOT/LICENSE" "$APP_DIR/LICENSE" - -ARCHIVE="$TARGET_DIR/release/kosmos-linux-${ARCH}.tar.gz" -rm -f "$ARCHIVE" -tar -czf "$ARCHIVE" -C "$TEMP_DIR" "Kosmos.app" - -echo "Bundle: $ARCHIVE" diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 3391e923..00000000 --- a/scripts/install.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env sh -# Install Kosmos for the current user under ~/.local. -# Downloads the latest release tarball from GitHub unless KOSMOS_BUNDLE_PATH -# is set to an existing local tarball. -set -eu - -REPO="${KOSMOS_REPO:-etchebarne/kosmos}" -APP_ID="net.etchebarne.Kosmos" -INSTALL_ROOT="$HOME/.local" -APP_DIR="$INSTALL_ROOT/Kosmos.app" -BIN_LINK="$INSTALL_ROOT/bin/kosmos" -DESKTOP_FILE="$INSTALL_ROOT/share/applications/$APP_ID.desktop" - -arch="$(uname -m)" -case "$arch" in - x86_64|amd64) arch="x86_64" ;; - *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; -esac - -if [ -n "${KOSMOS_BUNDLE_PATH:-}" ]; then - [ -f "$KOSMOS_BUNDLE_PATH" ] || { echo "KOSMOS_BUNDLE_PATH not found: $KOSMOS_BUNDLE_PATH" >&2; exit 1; } - tarball="$KOSMOS_BUNDLE_PATH" -else - if command -v curl >/dev/null 2>&1; then - fetch() { curl -fL "$@"; } - elif command -v wget >/dev/null 2>&1; then - fetch() { wget -O- "$@"; } - else - echo "Need curl or wget on PATH" >&2 - exit 1 - fi - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT - tarball="$tmp/kosmos-linux-$arch.tar.gz" - url="https://github.com/$REPO/releases/latest/download/kosmos-linux-$arch.tar.gz" - echo "Downloading $url" - fetch "$url" > "$tarball" -fi - -mkdir -p "$INSTALL_ROOT/bin" "$INSTALL_ROOT/share/applications" -rm -rf "$APP_DIR" -tar -xzf "$tarball" -C "$INSTALL_ROOT" - -ln -sf "$APP_DIR/bin/kosmos" "$BIN_LINK" - -src_desktop="$APP_DIR/share/applications/$APP_ID.desktop" -icon_path="$APP_DIR/share/icons/hicolor/512x512/apps/kosmos.png" -sed -e "s|^Icon=kosmos\$|Icon=$icon_path|" \ - -e "s|^Exec=kosmos |Exec=$APP_DIR/bin/kosmos |" \ - -e "s|^TryExec=kosmos\$|TryExec=$APP_DIR/bin/kosmos|" \ - "$src_desktop" > "$DESKTOP_FILE" - -if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database "$INSTALL_ROOT/share/applications" >/dev/null 2>&1 || true -fi - -echo "Installed to $APP_DIR" -case ":$PATH:" in - *":$INSTALL_ROOT/bin:"*) echo "Run with: kosmos" ;; - *) echo "Add $INSTALL_ROOT/bin to PATH, or run: $BIN_LINK" ;; -esac diff --git a/scripts/package-linux-appimage.sh b/scripts/package-linux-appimage.sh deleted file mode 100755 index c41c8edf..00000000 --- a/scripts/package-linux-appimage.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bash -# Build an AppImage from the Linux Kosmos.app bundle. -# Input: target/release/kosmos-linux-.tar.gz from bundle-linux.sh -# Output: target/release/Kosmos--.AppImage -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" -RELEASE_DIR="$TARGET_DIR/release" -APPIMAGETOOL="${APPIMAGETOOL:-appimagetool}" - -require_command() { - local command_name="$1" - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "Missing required command: $command_name" >&2 - exit 1 - fi -} - -workspace_version() { - awk ' - /^\[workspace\.package\]/ { in_workspace_package = 1; next } - /^\[/ { in_workspace_package = 0 } - in_workspace_package && /^version *= *"/ { - gsub(/^version *= *"/, "") - gsub(/".*/, "") - print - exit - } - ' "$ROOT/Cargo.toml" -} - -set_appimage_arch() { - local system_arch="$1" - case "$system_arch" in - x86_64|amd64) - BUNDLE_ARCH="x86_64" - APPIMAGE_ARCH="x86_64" - ;; - *) - echo "Unsupported AppImage architecture: $system_arch" >&2 - exit 1 - ;; - esac -} - -extract_bundle() { - local archive="$1" - local destination="$2" - - if [ ! -f "$archive" ]; then - echo "Missing bundle: $archive" >&2 - echo "Run ./scripts/bundle-linux.sh first." >&2 - exit 1 - fi - - tar -xzf "$archive" -C "$destination" -} - -write_app_run() { - local app_dir="$1" - - cat > "$app_dir/AppRun" <<'SH' -#!/usr/bin/env sh -set -eu - -APPDIR="$(dirname "$(readlink -f "$0")")" -export KOSMOS_APP_ID="${KOSMOS_APP_ID:-net.etchebarne.Kosmos.AppImage}" -export LD_LIBRARY_PATH="$APPDIR/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - -desktop_paths() { - data_home="${XDG_DATA_HOME:-$HOME/.local/share}" - desktop_dir="$data_home/applications" - icon_dir="$data_home/icons/hicolor/512x512/apps" - desktop_file="$desktop_dir/$KOSMOS_APP_ID.desktop" - icon_file="$icon_dir/kosmos-appimage.png" -} - -escape_desktop_exec_path() { - printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/`/\\`/g; s/\$/\\$/g' -} - -refresh_desktop_metadata() { - data_home="${XDG_DATA_HOME:-$HOME/.local/share}" - desktop_dir="$data_home/applications" - - if command -v gtk-update-icon-cache >/dev/null 2>&1; then - gtk-update-icon-cache -q -t -f "$data_home/icons/hicolor" >/dev/null 2>&1 || true - fi - if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database "$desktop_dir" >/dev/null 2>&1 || true - fi - if command -v kbuildsycoca6 >/dev/null 2>&1; then - kbuildsycoca6 --noincremental >/dev/null 2>&1 || true - elif command -v kbuildsycoca5 >/dev/null 2>&1; then - kbuildsycoca5 --noincremental >/dev/null 2>&1 || true - fi -} - -write_desktop_file() { - escaped_appimage="$(escape_desktop_exec_path "$APPIMAGE")" - - cat > "$desktop_file" < "$app_dir/${appimage_app_id}.desktop" - - cp "$bundle_dir/share/icons/hicolor/512x512/apps/kosmos.png" "$app_dir/kosmos.png" - cp "$app_dir/kosmos.png" "$app_dir/.DirIcon" - write_app_run "$app_dir" -} - -require_command envsubst -require_command "$APPIMAGETOOL" - -KOSMOS_VERSION="$(workspace_version)" -if [ -z "$KOSMOS_VERSION" ]; then - echo "Could not detect version in Cargo.toml" >&2 - exit 1 -fi - -set_appimage_arch "$(uname -m)" - -ARCHIVE="$RELEASE_DIR/kosmos-linux-$BUNDLE_ARCH.tar.gz" -OUTPUT="$RELEASE_DIR/Kosmos-${KOSMOS_VERSION}-${APPIMAGE_ARCH}.AppImage" - -TEMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TEMP_DIR"' EXIT - -extract_bundle "$ARCHIVE" "$TEMP_DIR" -BUNDLE_DIR="$TEMP_DIR/Kosmos.app" -APP_DIR="$TEMP_DIR/Kosmos.AppDir" - -prepare_app_dir "$BUNDLE_DIR" "$APP_DIR" - -rm -f "$OUTPUT" -ARCH="$APPIMAGE_ARCH" APPIMAGE_EXTRACT_AND_RUN=1 \ - "$APPIMAGETOOL" "$APP_DIR" "$OUTPUT" - -echo "AppImage: $OUTPUT" diff --git a/scripts/package-linux-deb-rpm.sh b/scripts/package-linux-deb-rpm.sh deleted file mode 100755 index b3f7358b..00000000 --- a/scripts/package-linux-deb-rpm.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -# Build Debian and RPM packages from the Linux Kosmos.app bundle. -# Input: target/release/kosmos-linux-.tar.gz from bundle-linux.sh -# Outputs: target/release/kosmos__.deb -# target/release/kosmos--1..rpm -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" -RELEASE_DIR="$TARGET_DIR/release" - -require_command() { - local command_name="$1" - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "Missing required command: $command_name" >&2 - exit 1 - fi -} - -workspace_version() { - awk ' - /^\[workspace\.package\]/ { in_workspace_package = 1; next } - /^\[/ { in_workspace_package = 0 } - in_workspace_package && /^version *= *"/ { - gsub(/^version *= *"/, "") - gsub(/".*/, "") - print - exit - } - ' "$ROOT/Cargo.toml" -} - -set_package_arches() { - local system_arch="$1" - case "$system_arch" in - x86_64|amd64) - BUNDLE_ARCH="x86_64" - DEB_ARCH="amd64" - RPM_ARCH="x86_64" - ;; - *) - echo "Unsupported package architecture: $system_arch" >&2 - exit 1 - ;; - esac -} - -extract_bundle() { - local archive="$1" - local destination="$2" - - if [ ! -f "$archive" ]; then - echo "Missing bundle: $archive" >&2 - echo "Run ./scripts/bundle-linux.sh first." >&2 - exit 1 - fi - - tar -xzf "$archive" -C "$destination" -} - -install_package_files() { - local app_dir="$1" - local package_root="$2" - - mkdir -p "$package_root/opt" \ - "$package_root/usr/bin" \ - "$package_root/usr/share/applications" \ - "$package_root/usr/share/icons" \ - "$package_root/usr/share/licenses/kosmos" - - cp -a "$app_dir" "$package_root/opt/Kosmos.app" - ln -s /opt/Kosmos.app/bin/kosmos "$package_root/usr/bin/kosmos" - cp -a "$app_dir/share/applications/net.etchebarne.Kosmos.desktop" \ - "$package_root/usr/share/applications/net.etchebarne.Kosmos.desktop" - cp -a "$app_dir/share/icons/hicolor" "$package_root/usr/share/icons/" - cp -a "$app_dir/LICENSE" "$package_root/usr/share/licenses/kosmos/LICENSE" -} - -build_deb_package() { - local app_dir="$1" - local temp_dir="$2" - local output="$3" - local package_root="$temp_dir/deb-root" - - mkdir -p "$package_root/DEBIAN" - install_package_files "$app_dir" "$package_root" - - export KOSMOS_VERSION DEB_ARCH - envsubst < "$ROOT/packaging/linux/deb-control.in" > "$package_root/DEBIAN/control" - - rm -f "$output" - dpkg-deb --build --root-owner-group "$package_root" "$output" -} - -build_rpm_package() { - local app_dir="$1" - local temp_dir="$2" - local output="$3" - local rpm_topdir="$temp_dir/rpmbuild" - local spec_file="$rpm_topdir/SPECS/kosmos.spec" - - mkdir -p "$rpm_topdir/BUILD" \ - "$rpm_topdir/BUILDROOT" \ - "$rpm_topdir/RPMS" \ - "$rpm_topdir/SOURCES" \ - "$rpm_topdir/SPECS" \ - "$rpm_topdir/SRPMS" - - export KOSMOS_VERSION RPM_ARCH KOSMOS_APP_DIR="$app_dir" - envsubst < "$ROOT/packaging/linux/kosmos.spec.in" > "$spec_file" - - rpmbuild -bb "$spec_file" --define "_topdir $rpm_topdir" - - rm -f "$output" - cp "$rpm_topdir/RPMS/$RPM_ARCH/kosmos-$KOSMOS_VERSION-1.$RPM_ARCH.rpm" "$output" -} - -require_command dpkg-deb -require_command envsubst -require_command rpmbuild - -KOSMOS_VERSION="$(workspace_version)" -if [ -z "$KOSMOS_VERSION" ]; then - echo "Could not detect version in Cargo.toml" >&2 - exit 1 -fi - -set_package_arches "$(uname -m)" - -ARCHIVE="$RELEASE_DIR/kosmos-linux-$BUNDLE_ARCH.tar.gz" -DEB_OUTPUT="$RELEASE_DIR/kosmos_${KOSMOS_VERSION}_${DEB_ARCH}.deb" -RPM_OUTPUT="$RELEASE_DIR/kosmos-${KOSMOS_VERSION}-1.${RPM_ARCH}.rpm" - -TEMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TEMP_DIR"' EXIT - -extract_bundle "$ARCHIVE" "$TEMP_DIR" -APP_DIR="$TEMP_DIR/Kosmos.app" - -build_deb_package "$APP_DIR" "$TEMP_DIR" "$DEB_OUTPUT" -build_rpm_package "$APP_DIR" "$TEMP_DIR" "$RPM_OUTPUT" - -echo "Debian package: $DEB_OUTPUT" -echo "RPM package: $RPM_OUTPUT" diff --git a/scripts/tag-version.sh b/scripts/tag-version.sh deleted file mode 100755 index 0fcea374..00000000 --- a/scripts/tag-version.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -REMOTE=${1:-origin} -CURRENT=$(awk '/^\[workspace\.package\]/{f=1; next} f && /^version *= *"/{ match($0, /"([^"]+)"/, a); print a[1]; exit }' Cargo.toml) - -if [ -z "$CURRENT" ]; then - echo "Could not detect current version in Cargo.toml" >&2 - exit 1 -fi - -TAG="v$CURRENT" -COMMIT=$(git rev-parse --short HEAD) -SUBJECT=$(git log -1 --pretty=%s) - -LOCAL_TAG="no" -if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - LOCAL_TAG="yes" -fi - -REMOTE_TAG="no" -if git ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null 2>&1; then - REMOTE_TAG="yes" -fi - -echo "Tag release version" -echo "" -echo " Version: $CURRENT" -echo " Tag: $TAG" -echo " Commit: $COMMIT $SUBJECT" -echo " Remote: $REMOTE" -echo " Local tag: $LOCAL_TAG" -echo " Remote tag: $REMOTE_TAG" -echo "" - -if [ "$LOCAL_TAG" = "yes" ] || [ "$REMOTE_TAG" = "yes" ]; then - echo "Refusing to continue because $TAG already exists." - exit 1 -fi - -read -r -p "Create local tag $TAG on HEAD? [y/N] " CREATE_TAG -case "$CREATE_TAG" in - y|Y|yes|YES) ;; - *) - echo "Cancelled." - exit 1 - ;; -esac - -git tag "$TAG" -echo "Created local tag $TAG." -echo "" -echo "About to push: $TAG -> $REMOTE" -read -r -p "Type PUSH to push $TAG to $REMOTE: " CONFIRM_PUSH - -if [ "$CONFIRM_PUSH" != "PUSH" ]; then - echo "Push cancelled. Local tag $TAG was created but not pushed." - exit 1 -fi - -git push "$REMOTE" "$TAG" -echo "Pushed $TAG to $REMOTE." diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh deleted file mode 100755 index 51bfcfb2..00000000 --- a/scripts/uninstall.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env sh -# Remove a user-level Kosmos install (the one created by install.sh). -# Does not touch system installs (e.g. AUR / pacman) — use `pacman -R kosmos-bin` for those. -set -eu - -APP_ID="net.etchebarne.Kosmos" -INSTALL_ROOT="$HOME/.local" -APP_DIR="$INSTALL_ROOT/Kosmos.app" -BIN_LINK="$INSTALL_ROOT/bin/kosmos" -DESKTOP_FILE="$INSTALL_ROOT/share/applications/$APP_ID.desktop" - -removed=0 - -if [ -d "$APP_DIR" ]; then - rm -rf "$APP_DIR" - echo "Removed $APP_DIR" - removed=1 -fi - -if [ -L "$BIN_LINK" ] || [ -f "$BIN_LINK" ]; then - rm -f "$BIN_LINK" - echo "Removed $BIN_LINK" - removed=1 -fi - -if [ -f "$DESKTOP_FILE" ]; then - rm -f "$DESKTOP_FILE" - echo "Removed $DESKTOP_FILE" - removed=1 -fi - -if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database "$INSTALL_ROOT/share/applications" >/dev/null 2>&1 || true -fi - -if [ "$removed" -eq 0 ]; then - echo "No user-level Kosmos install found under $INSTALL_ROOT" -else - echo "Uninstalled." -fi diff --git a/vendor/gpui-component/Cargo.toml b/vendor/gpui-component/Cargo.toml deleted file mode 100644 index a1f216fb..00000000 --- a/vendor/gpui-component/Cargo.toml +++ /dev/null @@ -1,110 +0,0 @@ -[workspace] -default-members = ["crates/ui"] -members = ["crates/macros", "crates/ui", "crates/assets"] -resolver = "2" - -[workspace.package] -publish = false -edition = "2024" - -[workspace.dependencies] -gpui-component = { path = "crates/ui", version = "0.5.2" } -gpui-component-macros = { path = "crates/macros", version = "0.5.1" } -gpui-component-assets = { path = "crates/assets", version = "0.5.1" } - -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland", "runtime_shaders"] } -gpui_web = { git = "https://github.com/zed-industries/zed" } -gpui_macros = { git = "https://github.com/zed-industries/zed" } -reqwest_client = { git = "https://github.com/zed-industries/zed" } -sum-tree = { version = "0.2.0", package = "zed-sum-tree" } -# reqwest = { version = "0.12.15-zed", package = "zed-reqwest" } -reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ - "charset", - "http2", - "macos-system-configuration", - "multipart", - "rustls-tls-native-roots", - "socks", - "stream", -], package = "zed-reqwest", version = "0.12.15-zed" } - -anyhow = "1" -log = "0.4" -lsp-types = { version = "0.97.0", features = ["proposed"] } -notify = "7.0.0" -raw-window-handle = "0.6.2" -ropey = { version = "=2.0.0-beta.1", features = [ - "metric_lines_lf", - "metric_utf16", -] } -rust-i18n = "4" -schemars = "1" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1" -serde_repr = "0.1" -smallvec = "1" -smol = "2" -tracing = "0.1.41" -wasm-bindgen = "0.2.113" -# Use errno stub for WASM -errno = { version = "0.3.14", default-features = false } - -[workspace.dependencies.windows] -features = ["Wdk", "Wdk_System", "Wdk_System_SystemServices"] -version = "0.58.0" - -[workspace.lints.clippy] -almost_complete_range = "allow" -arc_with_non_send_sync = "allow" -borrowed_box = "allow" -dbg_macro = "deny" -let_underscore_future = "allow" -manual_is_multiple_of = "allow" -map_entry = "allow" -module_inception = "allow" -non_canonical_partial_ord_impl = "allow" -reversed_empty_ranges = "allow" -single_range_in_vec_init = "allow" -style = { level = "allow", priority = -1 } -todo = "deny" -type_complexity = "allow" -# Temporarily allow this until we can migrate to the new APIs. -# https://github.com/longbridge/gpui-component/pull/2149 -useless_conversion = "allow" - -[profile.dev] -codegen-units = 16 -debug = "limited" -split-debuginfo = "unpacked" - -[profile.dev.package] -resvg = { opt-level = 3 } -rustybuzz = { opt-level = 3 } -taffy = { opt-level = 3 } -ttf-parser = { opt-level = 3 } -smol = { opt-level = 3 } -gpui = { opt-level = 3 } -gpui_platform = { opt-level = 3 } -gpui_macros = { opt-level = 3 } -tree-sitter = { opt-level = 3 } -sum_tree = { opt-level = 3 } -ropey = { opt-level = 3 } -lsp-types = { opt-level = 3 } -reqwest_client = { opt-level = 3 } -markdown = { opt-level = 3 } -xml5ever = { opt-level = 3 } - -[workspace.metadata.typos] -files.extend-exclude = ["**/fixtures/*"] - -[workspace.metadata.typos.default.extend-identifiers] -consts = "consts" - -[workspace.metadata.cargo-machete] -ignored = ["log", "anyhow", "serde"] - -# Patches for WASM compatibility -[patch.crates-io] -# Use stacker's psm version which may have better WASM support -psm = { git = "https://github.com/rust-lang/stacker", branch = "master" } diff --git a/vendor/gpui-component/LICENSE-APACHE b/vendor/gpui-component/LICENSE-APACHE deleted file mode 100644 index 3cc771d2..00000000 --- a/vendor/gpui-component/LICENSE-APACHE +++ /dev/null @@ -1,191 +0,0 @@ -Copyright 2024 - 2025 Longbridge - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-component/README.md b/vendor/gpui-component/README.md deleted file mode 100644 index 6b7f0d0e..00000000 --- a/vendor/gpui-component/README.md +++ /dev/null @@ -1,201 +0,0 @@ -# GPUI Component - -[English](./README.md) | [简体中文](./README.zh-CN.md) - -[![Build Status](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml/badge.svg)](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml) [![Docs](https://docs.rs/gpui-component/badge.svg)](https://docs.rs/gpui-component/) [![Crates.io](https://img.shields.io/crates/v/gpui-component.svg)](https://crates.io/crates/gpui-component) - -UI components for building fantastic desktop applications using [GPUI](https://gpui.rs). - -## Features - -- **Richness**: 60+ cross-platform desktop UI components. -- **Native**: Inspired by macOS and Windows controls, combined with shadcn/ui design for a modern experience. -- **Ease of Use**: Stateless `RenderOnce` components, simple and user-friendly. -- **Customizable**: Built-in `Theme` and `ThemeColor`, supporting multi-theme and variable-based configurations. -- **Versatile**: Supports sizes like `xs`, `sm`, `md`, and `lg`. -- **Flexible Layout**: Dock layout for panel arrangements, resizing, and freeform (Tiles) layouts. -- **High Performance**: Virtualized Table and List components for smooth large-data rendering. -- **Content Rendering**: Native support for Markdown and simple HTML. -- **Charting**: Built-in charts for visualizing your data. -- **Editor**: High performance code editor (Up to 200K lines for stable performance) with LSP (diagnostics, completion, hover, etc). -- **Syntax Highlighting**: Syntax highlighting for editor and markdown components using Tree Sitter. - -## Showcase - -https://longbridge.github.io/gpui-component/gallery/ - -Here is the first application: [Longbridge Pro](https://longbridge.com/desktop), built using GPUI Component. - -Image - -## Usage - -```toml -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } -gpui-component = { git = "https://github.com/longbridge/gpui-component" } -``` - -### Basic Example - -```rs -use gpui::*; -use gpui_component::{button::*, *}; - -pub struct HelloWorld; -impl Render for HelloWorld { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .v_flex() - .gap_2() - .size_full() - .items_center() - .justify_center() - .child("Hello, World!") - .child( - Button::new("ok") - .primary() - .label("Let's Go!") - .on_click(|_, _, _| println!("Clicked!")), - ) - } -} - -fn main() { - gpui_platform::application().run(move |cx| { - // This must be called before using any GPUI Component features. - gpui_component::init(cx); - - cx.spawn(async move |cx| { - cx.open_window(WindowOptions::default(), |window, cx| { - let view = cx.new(|_| HelloWorld); - // This first level on the window, should be a Root. - cx.new(|cx| Root::new(view, window, cx)) - }) - .expect("Failed to open window"); - }) - .detach(); - }); -} -``` - -### Icons - -GPUI Component has an `Icon` element, but it does not include SVG files by default. - -The example uses [Lucide](https://lucide.dev) icons, but you can use any icons you like. Just name the SVG files as defined in [IconName](https://github.com/longbridge/gpui-component/blob/main/crates/ui/src/icon.rs#L86). You can add any icons you need to your project. - -## Development - -### Desktop Gallery (Story) - -The `story` crate is a gallery application that showcases all available components. Run it with: - -```bash -cargo run -``` - -### Examples - -Some important examples are built into the `story` crate and can be run directly: - -```bash -# Code editor with LSP support and syntax highlighting -cargo run --example editor - -# Dock layout system (panels, split views, tabs) -cargo run --example dock - -# Markdown rendering -cargo run --example markdown - -# HTML rendering -cargo run --example html -``` - -The `examples` directory also contains standalone examples, each focused on a single feature. Each example is a separate crate, run them with `cargo run -p `: - -```bash -# Basic hello world -cargo run -p hello_world - -# System monitor (real-time charts with CPU/memory data) -cargo run -p system_monitor - -# Window title customization -cargo run -p window_title -``` - -### Web Gallery (WASM) - -You can also run the gallery in a web browser using WASM: - -```bash -cd crates/story-web - -# Install dependencies (first time only) -make install - -# Build and run development server -make dev -``` - -The gallery will be available at http://localhost:3000 - -For more details, see [crates/story-web/README.md](crates/story-web/README.md). - -Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more details. - -## Compare to others - -| Features | GPUI Component | [Iced] | [egui] | [Qt 6] | -| --------------------- | ------------------------------ | ------------------ | --------------------- | ------------------------------------------------- | -| Language | Rust | Rust | Rust | C++/QML | -| Core Render | GPUI | wgpu | wgpu | QT | -| License | Apache 2.0 | MIT | MIT/Apache 2.0 | [Commercial/LGPL](https://www.qt.io/qt-licensing) | -| Min Binary Size [^1] | 12MB | 11MB | 5M | 20MB [^2] | -| Cross-Platform | Yes | Yes | Yes | Yes | -| Documentation | Simple | Simple | Simple | Good | -| Web | Yes (WASM) | Yes | Yes | Yes | -| UI Style | Modern | Basic | Basic | Basic | -| CJK Support | Yes | Yes | Bad | Yes | -| Chart | Yes | No | No | Yes | -| Table (Large dataset) | Yes
(Virtual Rows, Columns) | No | Yes
(Virtual Rows) | Yes
(Virtual Rows, Columns) | -| Table Column Resize | Yes | No | Yes | Yes | -| Text base | Rope | [COSMIC Text] [^3] | trait TextBuffer [^4] | [QTextDocument] | -| CodeEditor | Simple | Simple | Simple | Basic API | -| Dock Layout | Yes | Yes | Yes | Yes | -| Syntax Highlight | [Tree Sitter] | [Syntect] | [Syntect] | [QSyntaxHighlighter] | -| Markdown Rendering | Yes | Yes | Basic | No | -| Markdown mix HTML | Yes | No | No | No | -| HTML Rendering | Basic | No | No | Basic | -| Text Selection | TextView | No | Any Label | Yes | -| Custom Theme | Yes | Yes | Yes | Yes | -| Built Themes | Yes | No | No | No | -| I18n | Yes | Yes | Yes | Yes | - -> Please submit an issue or PR if any mistakes or outdated are found. - -[Iced]: https://github.com/iced-rs/iced -[egui]: https://github.com/emilk/egui -[QT 6]: https://www.qt.io/product/qt6 -[Tree Sitter]: https://tree-sitter.github.io/tree-sitter/ -[Syntect]: https://github.com/trishume/syntect -[QSyntaxHighlighter]: https://doc.qt.io/qt-6/qsyntaxhighlighter.html -[QTextDocument]: https://doc.qt.io/qt-6/qtextdocument.html -[COSMIC Text]: https://github.com/pop-os/cosmic-text - -[^1]: Release builds by use simple hello world example. - -[^2]: [Reducing Binary Size of Qt Applications](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms) - -[^3]: Iced Editor: - -[^4]: egui TextBuffer: - -## License - -Apache-2.0 - -- UI design based on [shadcn/ui](https://ui.shadcn.com), some from [Reui](https://reui.io). -- Icons from [Lucide](https://lucide.dev). diff --git a/vendor/gpui-component/crates/assets/Cargo.toml b/vendor/gpui-component/crates/assets/Cargo.toml deleted file mode 100644 index e8124968..00000000 --- a/vendor/gpui-component/crates/assets/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "gpui-component-assets" -description = "Default bundled assets for GPUI Component." -keywords = ["desktop", "gpui", "shadcn", "ui", "uikit"] -license = "Apache-2.0" -documentation = "https://docs.rs/gpui-component" -homepage = "https://longbridge.github.io/gpui-component" -repository = "https://github.com/longbridge/gpui-component" -readme = "README.md" -edition.workspace = true -publish = true -version = "0.5.1" - -[lib] -doctest = false - -[dependencies] -anyhow.workspace = true -gpui.workspace = true -log.workspace = true - -[target.'cfg(not(target_family = "wasm"))'.dependencies] -rust-embed = { version = "8.7.2", features = ["interpolate-folder-path", "include-exclude"] } - -[target.'cfg(target_family = "wasm")'.dependencies] -reqwest = { workspace = true } -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" diff --git a/vendor/gpui-component/crates/assets/README.md b/vendor/gpui-component/crates/assets/README.md deleted file mode 100644 index 878057c2..00000000 --- a/vendor/gpui-component/crates/assets/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# GPUI Component Assets - -The default assets bundle for [GPUI Component](https://github.com/longbridge/gpui-component). - -## License - -Apache-2.0 diff --git a/vendor/gpui-component/crates/assets/assets/.gitkeep b/vendor/gpui-component/crates/assets/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/vendor/gpui-component/crates/assets/assets/icons/a-large-small.svg b/vendor/gpui-component/crates/assets/assets/icons/a-large-small.svg deleted file mode 100644 index cfba2deb..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/a-large-small.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/arrow-down.svg b/vendor/gpui-component/crates/assets/assets/icons/arrow-down.svg deleted file mode 100644 index bb5b04ca..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/arrow-down.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/arrow-left.svg b/vendor/gpui-component/crates/assets/assets/icons/arrow-left.svg deleted file mode 100644 index ed034950..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/arrow-left.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/arrow-right.svg b/vendor/gpui-component/crates/assets/assets/icons/arrow-right.svg deleted file mode 100644 index 519fffdc..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/arrow-right.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/arrow-up.svg b/vendor/gpui-component/crates/assets/assets/icons/arrow-up.svg deleted file mode 100644 index ad125196..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/arrow-up.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/asterisk.svg b/vendor/gpui-component/crates/assets/assets/icons/asterisk.svg deleted file mode 100644 index b2d266f7..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/asterisk.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery-charging.svg b/vendor/gpui-component/crates/assets/assets/icons/battery-charging.svg deleted file mode 100644 index 91392c3d..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery-charging.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery-full.svg b/vendor/gpui-component/crates/assets/assets/icons/battery-full.svg deleted file mode 100644 index 4279ecf9..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery-full.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery-low.svg b/vendor/gpui-component/crates/assets/assets/icons/battery-low.svg deleted file mode 100644 index 45bab8d5..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery-low.svg +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery-medium.svg b/vendor/gpui-component/crates/assets/assets/icons/battery-medium.svg deleted file mode 100644 index 65d6ac16..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery-medium.svg +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery-warning.svg b/vendor/gpui-component/crates/assets/assets/icons/battery-warning.svg deleted file mode 100644 index aa358196..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery-warning.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/battery.svg b/vendor/gpui-component/crates/assets/assets/icons/battery.svg deleted file mode 100644 index 96d8ce1d..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/battery.svg +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/bell.svg b/vendor/gpui-component/crates/assets/assets/icons/bell.svg deleted file mode 100644 index eecc3ef4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/bell.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/book-open.svg b/vendor/gpui-component/crates/assets/assets/icons/book-open.svg deleted file mode 100644 index 0264197a..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/book-open.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/bot.svg b/vendor/gpui-component/crates/assets/assets/icons/bot.svg deleted file mode 100644 index efcd1bbf..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/bot.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/building-2.svg b/vendor/gpui-component/crates/assets/assets/icons/building-2.svg deleted file mode 100644 index a5b21833..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/building-2.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/calendar.svg b/vendor/gpui-component/crates/assets/assets/icons/calendar.svg deleted file mode 100644 index 2724a480..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/calendar.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/case-sensitive.svg b/vendor/gpui-component/crates/assets/assets/icons/case-sensitive.svg deleted file mode 100644 index 648134fa..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/case-sensitive.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/chart-pie.svg b/vendor/gpui-component/crates/assets/assets/icons/chart-pie.svg deleted file mode 100644 index b8439f41..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chart-pie.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/check.svg b/vendor/gpui-component/crates/assets/assets/icons/check.svg deleted file mode 100644 index 16bb3793..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/chevron-down.svg b/vendor/gpui-component/crates/assets/assets/icons/chevron-down.svg deleted file mode 100644 index 1d316c76..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chevron-down.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/chevron-left.svg b/vendor/gpui-component/crates/assets/assets/icons/chevron-left.svg deleted file mode 100644 index f27e97cd..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chevron-left.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/chevron-right.svg b/vendor/gpui-component/crates/assets/assets/icons/chevron-right.svg deleted file mode 100644 index c1b76ee4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chevron-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/chevron-up.svg b/vendor/gpui-component/crates/assets/assets/icons/chevron-up.svg deleted file mode 100644 index f1bb1ad6..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chevron-up.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/chevrons-up-down.svg b/vendor/gpui-component/crates/assets/assets/icons/chevrons-up-down.svg deleted file mode 100644 index 106fac00..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/chevrons-up-down.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/circle-check.svg b/vendor/gpui-component/crates/assets/assets/icons/circle-check.svg deleted file mode 100644 index 4a7e1014..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/circle-check.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/circle-user.svg b/vendor/gpui-component/crates/assets/assets/icons/circle-user.svg deleted file mode 100644 index 1d9b51d1..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/circle-user.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/circle-x.svg b/vendor/gpui-component/crates/assets/assets/icons/circle-x.svg deleted file mode 100644 index 803c8cbe..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/circle-x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/close.svg b/vendor/gpui-component/crates/assets/assets/icons/close.svg deleted file mode 100644 index 346ec0b4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/close.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/copy.svg b/vendor/gpui-component/crates/assets/assets/icons/copy.svg deleted file mode 100644 index f3b629c6..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/copy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/cpu.svg b/vendor/gpui-component/crates/assets/assets/icons/cpu.svg deleted file mode 100644 index d2a40451..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/cpu.svg +++ /dev/null @@ -1,24 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/dash.svg b/vendor/gpui-component/crates/assets/assets/icons/dash.svg deleted file mode 100644 index a1264e78..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/dash.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/delete.svg b/vendor/gpui-component/crates/assets/assets/icons/delete.svg deleted file mode 100644 index eb058d9e..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/delete.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/ellipsis-vertical.svg b/vendor/gpui-component/crates/assets/assets/icons/ellipsis-vertical.svg deleted file mode 100644 index c75a25a4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/ellipsis-vertical.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/ellipsis.svg b/vendor/gpui-component/crates/assets/assets/icons/ellipsis.svg deleted file mode 100644 index 07dcab51..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/ellipsis.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/external-link.svg b/vendor/gpui-component/crates/assets/assets/icons/external-link.svg deleted file mode 100644 index dfef5df4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/external-link.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/eye-off.svg b/vendor/gpui-component/crates/assets/assets/icons/eye-off.svg deleted file mode 100644 index abf12804..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/eye-off.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/eye.svg b/vendor/gpui-component/crates/assets/assets/icons/eye.svg deleted file mode 100644 index 4b3a970b..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/eye.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/file.svg b/vendor/gpui-component/crates/assets/assets/icons/file.svg deleted file mode 100644 index 32c6b4a3..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/file.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/folder-closed.svg b/vendor/gpui-component/crates/assets/assets/icons/folder-closed.svg deleted file mode 100644 index aa197b01..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/folder-closed.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/folder-open.svg b/vendor/gpui-component/crates/assets/assets/icons/folder-open.svg deleted file mode 100644 index 594de4c1..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/folder-open.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/folder.svg b/vendor/gpui-component/crates/assets/assets/icons/folder.svg deleted file mode 100644 index c66c4721..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/folder.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/frame.svg b/vendor/gpui-component/crates/assets/assets/icons/frame.svg deleted file mode 100644 index 6511851a..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/frame.svg +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/gallery-vertical-end.svg b/vendor/gpui-component/crates/assets/assets/icons/gallery-vertical-end.svg deleted file mode 100644 index 39b1f806..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/gallery-vertical-end.svg +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/github.svg b/vendor/gpui-component/crates/assets/assets/icons/github.svg deleted file mode 100644 index 92adec97..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/github.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/globe.svg b/vendor/gpui-component/crates/assets/assets/icons/globe.svg deleted file mode 100644 index 2082a439..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/globe.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/hard-drive.svg b/vendor/gpui-component/crates/assets/assets/icons/hard-drive.svg deleted file mode 100644 index d49ce556..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/hard-drive.svg +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/heart-off.svg b/vendor/gpui-component/crates/assets/assets/icons/heart-off.svg deleted file mode 100644 index 68cda020..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/heart-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/heart.svg b/vendor/gpui-component/crates/assets/assets/icons/heart.svg deleted file mode 100644 index 2f267c91..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/heart.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/inbox.svg b/vendor/gpui-component/crates/assets/assets/icons/inbox.svg deleted file mode 100644 index 15d6fae4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/inbox.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/info.svg b/vendor/gpui-component/crates/assets/assets/icons/info.svg deleted file mode 100644 index 8f9c59ad..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/info.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/inspector.svg b/vendor/gpui-component/crates/assets/assets/icons/inspector.svg deleted file mode 100644 index c3517a42..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/inspector.svg +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/layout-dashboard.svg b/vendor/gpui-component/crates/assets/assets/icons/layout-dashboard.svg deleted file mode 100644 index 15cb8de8..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/layout-dashboard.svg +++ /dev/null @@ -1,24 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/loader-circle.svg b/vendor/gpui-component/crates/assets/assets/icons/loader-circle.svg deleted file mode 100644 index 12cb72f6..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/loader-circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/loader.svg b/vendor/gpui-component/crates/assets/assets/icons/loader.svg deleted file mode 100644 index f6ff93fa..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/loader.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/map.svg b/vendor/gpui-component/crates/assets/assets/icons/map.svg deleted file mode 100644 index bb909ec3..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/map.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/maximize.svg b/vendor/gpui-component/crates/assets/assets/icons/maximize.svg deleted file mode 100644 index b3504b57..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/maximize.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/memory-stick.svg b/vendor/gpui-component/crates/assets/assets/icons/memory-stick.svg deleted file mode 100644 index 7d12b575..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/memory-stick.svg +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/menu.svg b/vendor/gpui-component/crates/assets/assets/icons/menu.svg deleted file mode 100644 index 6598697f..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/menu.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/minimize.svg b/vendor/gpui-component/crates/assets/assets/icons/minimize.svg deleted file mode 100644 index 3f81a42d..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/minimize.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/minus.svg b/vendor/gpui-component/crates/assets/assets/icons/minus.svg deleted file mode 100644 index ab04a168..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/minus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/moon.svg b/vendor/gpui-component/crates/assets/assets/icons/moon.svg deleted file mode 100644 index dfadc201..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/moon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/network.svg b/vendor/gpui-component/crates/assets/assets/icons/network.svg deleted file mode 100644 index a6f0aaa5..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/network.svg +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/palette.svg b/vendor/gpui-component/crates/assets/assets/icons/palette.svg deleted file mode 100644 index b50674d3..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/palette.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-bottom-open.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-bottom-open.svg deleted file mode 100644 index df77e5b2..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-bottom-open.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-bottom.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-bottom.svg deleted file mode 100644 index ebe599c7..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-bottom.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-left-close.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-left-close.svg deleted file mode 100644 index 5f26cc6d..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-left-close.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-left-open.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-left-open.svg deleted file mode 100644 index 579e4583..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-left-open.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-left.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-left.svg deleted file mode 100644 index 2eed2667..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-left.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-right-close.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-right-close.svg deleted file mode 100644 index 5fe9dcdb..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-right-close.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-right-open.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-right-open.svg deleted file mode 100644 index 3b5ff0b4..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-right-open.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/panel-right.svg b/vendor/gpui-component/crates/assets/assets/icons/panel-right.svg deleted file mode 100644 index d29a4a51..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/panel-right.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/pause.svg b/vendor/gpui-component/crates/assets/assets/icons/pause.svg deleted file mode 100644 index a36563a1..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/pause.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/play.svg b/vendor/gpui-component/crates/assets/assets/icons/play.svg deleted file mode 100644 index 2d5f0674..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/play.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/plus.svg b/vendor/gpui-component/crates/assets/assets/icons/plus.svg deleted file mode 100644 index 651bbd49..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/plus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/redo-2.svg b/vendor/gpui-component/crates/assets/assets/icons/redo-2.svg deleted file mode 100644 index 7ea3df93..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/redo-2.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/redo.svg b/vendor/gpui-component/crates/assets/assets/icons/redo.svg deleted file mode 100644 index b90dfa5c..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/redo.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/replace.svg b/vendor/gpui-component/crates/assets/assets/icons/replace.svg deleted file mode 100644 index 5d7b480c..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/replace.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/resize-corner.svg b/vendor/gpui-component/crates/assets/assets/icons/resize-corner.svg deleted file mode 100644 index 31065be5..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/resize-corner.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/search.svg b/vendor/gpui-component/crates/assets/assets/icons/search.svg deleted file mode 100644 index a6583491..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/settings-2.svg b/vendor/gpui-component/crates/assets/assets/icons/settings-2.svg deleted file mode 100644 index 31b06f70..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/settings-2.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/settings.svg b/vendor/gpui-component/crates/assets/assets/icons/settings.svg deleted file mode 100644 index 9cc2027a..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/settings.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/sort-ascending.svg b/vendor/gpui-component/crates/assets/assets/icons/sort-ascending.svg deleted file mode 100644 index bc17bd79..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/sort-ascending.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/sort-descending.svg b/vendor/gpui-component/crates/assets/assets/icons/sort-descending.svg deleted file mode 100644 index f41eecbe..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/sort-descending.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/square-terminal.svg b/vendor/gpui-component/crates/assets/assets/icons/square-terminal.svg deleted file mode 100644 index 917dd1fb..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/square-terminal.svg +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/star-fill.svg b/vendor/gpui-component/crates/assets/assets/icons/star-fill.svg deleted file mode 100644 index 3d542a9c..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/star-fill.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/star-off.svg b/vendor/gpui-component/crates/assets/assets/icons/star-off.svg deleted file mode 100644 index a2620605..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/star-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/star.svg b/vendor/gpui-component/crates/assets/assets/icons/star.svg deleted file mode 100644 index 8d931311..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/star.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/sun.svg b/vendor/gpui-component/crates/assets/assets/icons/sun.svg deleted file mode 100644 index 668d7789..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/sun.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vendor/gpui-component/crates/assets/assets/icons/thumbs-down.svg b/vendor/gpui-component/crates/assets/assets/icons/thumbs-down.svg deleted file mode 100644 index 816273e9..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/thumbs-down.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/thumbs-up.svg b/vendor/gpui-component/crates/assets/assets/icons/thumbs-up.svg deleted file mode 100644 index 01fae76f..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/thumbs-up.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/triangle-alert.svg b/vendor/gpui-component/crates/assets/assets/icons/triangle-alert.svg deleted file mode 100644 index 1861c2e9..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/triangle-alert.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/undo-2.svg b/vendor/gpui-component/crates/assets/assets/icons/undo-2.svg deleted file mode 100644 index 660356e6..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/undo-2.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/undo.svg b/vendor/gpui-component/crates/assets/assets/icons/undo.svg deleted file mode 100644 index e66126ab..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/undo.svg +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/user.svg b/vendor/gpui-component/crates/assets/assets/icons/user.svg deleted file mode 100644 index aabc54fb..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/user.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/vendor/gpui-component/crates/assets/assets/icons/window-close.svg b/vendor/gpui-component/crates/assets/assets/icons/window-close.svg deleted file mode 100644 index 62b6b0c7..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/window-close.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/window-maximize.svg b/vendor/gpui-component/crates/assets/assets/icons/window-maximize.svg deleted file mode 100644 index 9da4cd7e..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/window-maximize.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/window-minimize.svg b/vendor/gpui-component/crates/assets/assets/icons/window-minimize.svg deleted file mode 100644 index 708e27be..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/window-minimize.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/vendor/gpui-component/crates/assets/assets/icons/window-restore.svg b/vendor/gpui-component/crates/assets/assets/icons/window-restore.svg deleted file mode 100644 index 896b6b60..00000000 --- a/vendor/gpui-component/crates/assets/assets/icons/window-restore.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/vendor/gpui-component/crates/assets/src/lib.rs b/vendor/gpui-component/crates/assets/src/lib.rs deleted file mode 100644 index 2d923616..00000000 --- a/vendor/gpui-component/crates/assets/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -/// Embed application assets for GPUI Component. -/// -/// This assets provides icons svg files for [IconName](https://docs.rs/gpui-component/latest/gpui_component/enum.IconName.html). -/// -/// ## Usage -/// -/// ```rust,no_run -/// use gpui::*; -/// use gpui_component_assets::Assets; -/// -/// let app = gpui_platform::application().with_assets(Assets); -/// ``` -/// -/// ## Platform Differences -/// -/// - **Native (Desktop)**: Icons are embedded in the binary using RustEmbed -/// - **WASM (Web)**: Icons are downloaded from CDN using web_sys::Request -/// - This significantly reduces WASM bundle size -/// - Icons are downloaded on-demand when first used -/// - Downloaded icons are cached in memory -#[cfg(not(target_family = "wasm"))] -mod native_assets; - -#[cfg(target_family = "wasm")] -mod wasm_assets; - -#[cfg(not(target_family = "wasm"))] -pub use native_assets::Assets; - -#[cfg(target_family = "wasm")] -pub use wasm_assets::Assets; diff --git a/vendor/gpui-component/crates/assets/src/native_assets.rs b/vendor/gpui-component/crates/assets/src/native_assets.rs deleted file mode 100644 index 49e55eb9..00000000 --- a/vendor/gpui-component/crates/assets/src/native_assets.rs +++ /dev/null @@ -1,34 +0,0 @@ -use anyhow::anyhow; -use gpui::{AssetSource, Result, SharedString}; -use std::borrow::Cow; - -/// Native implementation using RustEmbed -#[derive(rust_embed::RustEmbed)] -#[folder = "assets"] -#[include = "icons/**/*.svg"] -pub struct Assets; - -impl Assets { - /// Create a new Assets instance. The endpoint parameter is ignored for native builds. - pub fn new(_endpoint: impl Into) -> Self { - Self - } -} - -impl AssetSource for Assets { - fn load(&self, path: &str) -> Result>> { - if path.is_empty() { - return Ok(None); - } - - Self::get(path) - .map(|f| Some(f.data)) - .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path)) - } - - fn list(&self, path: &str) -> Result> { - Ok(Self::iter() - .filter_map(|p| p.starts_with(path).then(|| p.into())) - .collect()) - } -} diff --git a/vendor/gpui-component/crates/assets/src/wasm_assets.rs b/vendor/gpui-component/crates/assets/src/wasm_assets.rs deleted file mode 100644 index 3da9268d..00000000 --- a/vendor/gpui-component/crates/assets/src/wasm_assets.rs +++ /dev/null @@ -1,108 +0,0 @@ -use anyhow::anyhow; -use gpui::{AssetSource, Result, SharedString}; -use std::borrow::Cow; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; -use wasm_bindgen_futures::spawn_local; - -/// WASM implementation - download assets on-demand -pub struct Assets { - endpoint: SharedString, - cache: Arc>>>, - pending: Arc>>, -} - -impl Assets { - pub fn new(endpoint: impl Into) -> Self { - Self { - endpoint: endpoint.into(), - cache: Arc::new(RwLock::new(HashMap::new())), - pending: Arc::new(RwLock::new(HashMap::new())), - } - } -} - -impl Default for Assets { - fn default() -> Self { - Self::new("") - } -} - -impl AssetSource for Assets { - fn load(&self, path: &str) -> Result>> { - if path.is_empty() { - return Ok(None); - } - - if path.starts_with("icons/") && path.ends_with(".svg") { - // Check if already cached - if let Ok(cache) = self.cache.read() { - if let Some(data) = cache.get(path) { - return Ok(Some(Cow::Owned(data.clone()))); - } - } - - // Check if download is already pending - let is_pending = self - .pending - .read() - .map(|p| p.contains_key(path)) - .unwrap_or(false); - - if !is_pending { - // Mark as pending and start download - if let Ok(mut pending) = self.pending.write() { - pending.insert(path.to_string(), true); - } - - let url = format!("{}/assets/{}", self.endpoint, path); - let path_clone = path.to_string(); - let cache = self.cache.clone(); - let pending = self.pending.clone(); - - spawn_local(async move { - match reqwest::get(&url).await { - Ok(response) => { - if response.status().is_success() { - match response.bytes().await { - Ok(bytes) => { - if let Ok(mut cache) = cache.write() { - cache.insert(path_clone.clone(), bytes.to_vec()); - } - } - Err(e) => { - log::warn!("Failed to read icon {}: {}", path_clone, e); - } - } - } else { - log::warn!( - "Failed to download icon {}: HTTP {}", - path_clone, - response.status() - ); - } - } - Err(e) => { - log::warn!("Failed to fetch icon {}: {}", path_clone, e); - } - } - - // Remove from pending - if let Ok(mut pending) = pending.write() { - pending.remove(&path_clone); - } - }); - } - - // Return error so GPUI will retry (but only log once per icon) - Err(anyhow!("Wasm assets loading, will be available soon...")) - } else { - Ok(None) - } - } - - fn list(&self, path: &str) -> Result> { - let _ = path; - Ok(Vec::new()) - } -} diff --git a/vendor/gpui-component/crates/macros/Cargo.toml b/vendor/gpui-component/crates/macros/Cargo.toml deleted file mode 100644 index 3deae6dc..00000000 --- a/vendor/gpui-component/crates/macros/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "gpui-component-macros" -description = "Macros for GPUI Component." -version = "0.5.1" -license = "Apache-2.0" -publish = true -edition.workspace = true - -[lib] -proc-macro = true - -[lints] -workspace = true - -[dependencies] -proc-macro2 = "1.0" -quote = "1.0" -syn = "2.0" - -[package.metadata.cargo-machete] -ignored = ["proc-macro2", "core-text"] diff --git a/vendor/gpui-component/crates/macros/LICENSE-APACHE b/vendor/gpui-component/crates/macros/LICENSE-APACHE deleted file mode 120000 index 1cd601d0..00000000 --- a/vendor/gpui-component/crates/macros/LICENSE-APACHE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-APACHE \ No newline at end of file diff --git a/vendor/gpui-component/crates/macros/src/derive_into_plot.rs b/vendor/gpui-component/crates/macros/src/derive_into_plot.rs deleted file mode 100644 index 850556d1..00000000 --- a/vendor/gpui-component/crates/macros/src/derive_into_plot.rs +++ /dev/null @@ -1,73 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, DeriveInput}; - -pub fn derive_into_plot(input: TokenStream) -> TokenStream { - let ast = parse_macro_input!(input as DeriveInput); - let type_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); - - let expanded = quote! { - impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } - } - - impl #impl_generics gpui::Element for #type_name #type_generics #where_clause { - type RequestLayoutState = (); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut gpui::Window, - cx: &mut gpui::App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let style = gpui::Style { - size: gpui::Size::full(), - ..Default::default() - }; - - (window.request_layout(style, None, cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: gpui::Bounds, - _: &mut Self::RequestLayoutState, - _: &mut gpui::Window, - _: &mut gpui::App, - ) -> Self::PrepaintState { - } - - fn paint( - &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - bounds: gpui::Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut gpui::Window, - cx: &mut gpui::App, - ) { - ::paint(self, bounds, window, cx) - } - } - }; - - TokenStream::from(expanded) -} diff --git a/vendor/gpui-component/crates/macros/src/lib.rs b/vendor/gpui-component/crates/macros/src/lib.rs deleted file mode 100644 index 56750a11..00000000 --- a/vendor/gpui-component/crates/macros/src/lib.rs +++ /dev/null @@ -1,209 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; -use syn::parse::{Parse, ParseStream}; - -mod derive_into_plot; - -/// Input for icon_name! macro: EnumName, "path", [optional derives] -struct IconNameInput { - enum_name: syn::Ident, - _comma: syn::Token![,], - path: syn::LitStr, - derives: Option<( - syn::Token![,], - syn::punctuated::Punctuated, - )>, -} - -impl Parse for IconNameInput { - fn parse(input: ParseStream) -> syn::Result { - let enum_name = input.parse()?; - let _comma = input.parse()?; - let path = input.parse()?; - - // Check if there's an optional derives list - let derives = if input.peek(syn::Token![,]) { - let comma = input.parse()?; - let content; - syn::bracketed!(content in input); - let derives = content.parse_terminated(syn::Path::parse, syn::Token![,])?; - Some((comma, derives)) - } else { - None - }; - - Ok(IconNameInput { - enum_name, - _comma, - path, - derives, - }) - } -} - -#[proc_macro_derive(IntoPlot)] -pub fn derive_into_plot(input: TokenStream) -> TokenStream { - derive_into_plot::derive_into_plot(input) -} - -/// Convert an SVG filename to PascalCase identifier. -/// -/// Strips `.svg` extension, splits on separators (`-`, `_`, `.`), -/// and capitalizes each word following Rust naming conventions. -/// -/// # Examples -/// -/// ```ignore -/// assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight"); -/// assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName"); -/// assert_eq!(pascal_case("icon-123.svg"), "Icon123"); -/// ``` -fn pascal_case(filename: &str) -> String { - filename - .strip_suffix(".svg") - .unwrap_or(filename) - .split(|c: char| c == '-' || c == '_' || c == '.') - .filter(|part| !part.is_empty()) - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) if first.is_ascii_digit() => word.to_string(), - Some(first) => { - let mut result = String::with_capacity(word.len()); - result.extend(first.to_uppercase()); - result.push_str(&chars.as_str().to_lowercase()); - result - } - } - }) - .collect() -} - -/// Generate a custom icon enum and its `IconNamed` impl by scanning a directory of SVG files. -/// -/// Accepts an enum name, a path relative to the calling crate's `CARGO_MANIFEST_DIR`, -/// and optionally a list of additional derive traits. -/// Each `.svg` file becomes an enum variant using PascalCase conversion. -/// -/// # Example -/// -/// ```ignore -/// // Basic usage (derives IntoElement, Clone by default) -/// icon_named!(IconName, "../assets/assets/icons"); -/// -/// // With custom derives -/// icon_named!(IconName, "../assets/assets/icons", [Debug, Copy, PartialEq, Eq]); -/// ``` -#[proc_macro] -pub fn icon_named(input: TokenStream) -> TokenStream { - let IconNameInput { - enum_name, - path, - derives, - .. - } = syn::parse_macro_input!(input as IconNameInput); - - let relative_path = path.value(); - - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - let icons_dir = std::path::Path::new(&manifest_dir).join(&relative_path); - - let mut entries: Vec<(String, String)> = Vec::new(); - - let dir = std::fs::read_dir(&icons_dir).unwrap_or_else(|e| { - panic!( - "generate_icon_enum: failed to read '{}': {}", - icons_dir.display(), - e - ) - }); - - for entry in dir { - let entry = entry.expect("failed to read directory entry"); - let filename = entry.file_name().to_string_lossy().to_string(); - if filename.ends_with(".svg") { - let variant_name = pascal_case(&filename); - let path = format!("icons/{}", filename); - entries.push((variant_name, path)); - } - } - - entries.sort_by(|a, b| a.0.cmp(&b.0)); - - let variants: Vec = entries - .iter() - .map(|(name, _)| proc_macro2::Ident::new(name, proc_macro2::Span::call_site())) - .collect(); - let paths: Vec<&str> = entries.iter().map(|(_, p)| p.as_str()).collect(); - - // Build derive list: always include IntoElement and Clone, then add custom derives - let derive_attrs = if let Some((_, custom_derives)) = derives { - let derives_vec: Vec<_> = custom_derives.iter().collect(); - quote! { - #[derive(IntoElement, Clone, #(#derives_vec),*)] - } - } else { - quote! { - #[derive(IntoElement, Clone)] - } - }; - - let expanded = quote! { - #derive_attrs - - pub enum #enum_name { - #(#variants,)* - } - - impl IconNamed for #enum_name { - fn path(self) -> SharedString { - match self { - #(Self::#variants => #paths,)* - } - .into() - } - } - }; - - TokenStream::from(expanded) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pascal_case_basic() { - assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight"); - assert_eq!(pascal_case("home.svg"), "Home"); - assert_eq!(pascal_case("x-circle.svg"), "XCircle"); - - assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName"); - assert_eq!(pascal_case("arrow_up_down.svg"), "ArrowUpDown"); - - assert_eq!(pascal_case("kebab-case_mixed.svg"), "KebabCaseMixed"); - assert_eq!(pascal_case("icon-with_under.svg"), "IconWithUnder"); - - assert_eq!(pascal_case("icon-123.svg"), "Icon123"); - assert_eq!(pascal_case("arrow-2x.svg"), "Arrow2x"); - assert_eq!(pascal_case("24-hour.svg"), "24Hour"); - - assert_eq!(pascal_case("arrow--right.svg"), "ArrowRight"); - assert_eq!(pascal_case("icon__name.svg"), "IconName"); - assert_eq!(pascal_case("multiple---dash.svg"), "MultipleDash"); - - assert_eq!(pascal_case("a.svg"), "A"); - assert_eq!(pascal_case("-leading.svg"), "Leading"); - assert_eq!(pascal_case("trailing-.svg"), "Trailing"); - assert_eq!(pascal_case("-.svg"), ""); - - assert_eq!(pascal_case("arrow-right"), "ArrowRight"); - assert_eq!(pascal_case("home"), "Home"); - - assert_eq!(pascal_case("hello.svg"), "Hello"); - assert_eq!(pascal_case("WORLD.svg"), "World"); - assert_eq!(pascal_case("iOS-icon.svg"), "IosIcon"); - assert_eq!(pascal_case("API-key.svg"), "ApiKey"); - } -} diff --git a/vendor/gpui-component/crates/ui/Cargo.toml b/vendor/gpui-component/crates/ui/Cargo.toml deleted file mode 100644 index 56287c7d..00000000 --- a/vendor/gpui-component/crates/ui/Cargo.toml +++ /dev/null @@ -1,196 +0,0 @@ -[package] -description = "UI components for building fantastic desktop application by using GPUI." -keywords = ["desktop", "gpui", "shadcn", "ui", "uikit"] -license = "Apache-2.0" -name = "gpui-component" -documentation = "https://docs.rs/gpui-component" -homepage = "https://longbridge.github.io/gpui-component" -repository = "https://github.com/longbridge/gpui-component" -readme = "../../README.md" -version = "0.5.2" -publish = true -edition.workspace = true - -[lib] -doctest = false - -[features] -decimal = ["dep:rust_decimal"] -inspector = ["gpui_macros/inspector", "gpui/inspector"] - -# For syntax highlighting in Markdown and CodeEditor. -tree-sitter-languages = [ - "tree-sitter-astro", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-cmake", - "tree-sitter-cpp", - "tree-sitter-csharp", - "tree-sitter-css", - "tree-sitter-diff", - "tree-sitter-ejs", - "tree-sitter-elixir", - "tree-sitter-erb", - "tree-sitter-go", - "tree-sitter-graphql", - "tree-sitter-html", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-jsdoc", - "tree-sitter-kotlin", - "tree-sitter-lua", - "tree-sitter-make", - "tree-sitter-markdown", - "tree-sitter-php", - "tree-sitter-proto", - "tree-sitter-python", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-scala", - "tree-sitter-sql", - "tree-sitter-svelte", - "tree-sitter-swift", - "tree-sitter-toml", - "tree-sitter-tsx", - "tree-sitter-typescript", - "tree-sitter-xml", - "tree-sitter-yaml", - "tree-sitter-zig", -] -tree-sitter-astro = ["dep:tree-sitter-astro-next"] -tree-sitter-bash = ["dep:tree-sitter-bash"] -tree-sitter-c = ["dep:tree-sitter-c"] -tree-sitter-cmake = ["dep:tree-sitter-cmake"] -tree-sitter-cpp = ["dep:tree-sitter-cpp"] -tree-sitter-csharp = ["dep:tree-sitter-c-sharp"] -tree-sitter-css = ["dep:tree-sitter-css"] -tree-sitter-diff = ["dep:tree-sitter-diff"] -tree-sitter-ejs = ["dep:tree-sitter-embedded-template"] -tree-sitter-elixir = ["dep:tree-sitter-elixir"] -tree-sitter-erb = ["dep:tree-sitter-embedded-template"] -tree-sitter-go = ["dep:tree-sitter-go"] -tree-sitter-graphql = ["dep:tree-sitter-graphql"] -tree-sitter-html = ["dep:tree-sitter-html"] -tree-sitter-java = ["dep:tree-sitter-java"] -tree-sitter-javascript = ["dep:tree-sitter-javascript"] -tree-sitter-jsdoc = ["dep:tree-sitter-jsdoc"] -tree-sitter-kotlin = ["dep:tree-sitter-kotlin-sg"] -tree-sitter-lua = ["dep:tree-sitter-lua"] -tree-sitter-make = ["dep:tree-sitter-make"] -tree-sitter-markdown = ["dep:tree-sitter-md"] -tree-sitter-markdown-inline = ["tree-sitter-markdown"] -tree-sitter-php = ["dep:tree-sitter-php"] -tree-sitter-proto = ["dep:tree-sitter-proto"] -tree-sitter-python = ["dep:tree-sitter-python"] -tree-sitter-ruby = ["dep:tree-sitter-ruby"] -tree-sitter-rust = ["dep:tree-sitter-rust"] -tree-sitter-scala = ["dep:tree-sitter-scala"] -tree-sitter-sql = ["dep:tree-sitter-sequel"] -tree-sitter-svelte = ["dep:tree-sitter-svelte-next"] -tree-sitter-swift = ["dep:tree-sitter-swift"] -tree-sitter-toml = ["dep:tree-sitter-toml-ng"] -tree-sitter-tsx = ["dep:tree-sitter-typescript"] -tree-sitter-typescript = ["dep:tree-sitter-typescript"] -tree-sitter-xml = ["dep:tree-sitter-xml"] -tree-sitter-yaml = ["dep:tree-sitter-yaml"] -tree-sitter-zig = ["dep:tree-sitter-zig"] - -[dependencies] -anyhow.workspace = true -gpui = { workspace = true } -gpui_macros.workspace = true -gpui-component-macros = { workspace = true } -notify.workspace = true -ropey.workspace = true -rust-i18n.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_repr.workspace = true -smallvec.workspace = true -sum-tree.workspace = true -tracing.workspace = true -log.workspace = true - -enum-iterator = "2.1.0" -itertools = "0.13.0" -once_cell = "1.19.0" -paste = "1" -regex = "1" -unicode-segmentation = "1.12.0" -uuid = "1.10" - -# Async primitives (cross-platform, including WASM) -async-channel = "2.3.1" -futures = "0.3" - -# Chart -num-traits = "0.2" -rust_decimal = { version = "1.37.0", optional = true } - -# Markdown Parser -markdown = { version = "1.0.0", features = ["serde"] } - -# HTML Parser -html5ever = "0.27" -markup5ever_rcdom = "0.3.0" - -# Calendar -chrono = "0.4.38" - -# Code Editor -aho-corasick = "1.1.3" -lsp-types.workspace = true - -# Cross-platform time APIs (zero-cost on native, uses web APIs on WASM) -instant = { version = "0.1", features = ["wasm-bindgen"] } - -# Native-only dependencies (not available on WASM) -[target.'cfg(not(target_family = "wasm"))'.dependencies] -smol.workspace = true -tree-sitter = "0.26" -tree-sitter-astro-next = { version="0.1.1", optional = true } -tree-sitter-bash = { version = "0.23.3", optional = true } -tree-sitter-c = { version = "0.24.1", optional = true } -tree-sitter-c-sharp = { version = "0.23.1", optional = true } -tree-sitter-cmake = { version = "0.7.1", optional = true } -tree-sitter-cpp = { version = "0.23.4", optional = true } -tree-sitter-css = { version = "0.23.2", optional = true } -tree-sitter-diff = { version = "0.1.0", optional = true } -tree-sitter-elixir = { version = "0.3", optional = true } -tree-sitter-embedded-template = { version = "0.23.0", optional = true } -tree-sitter-go = { version = "0.23.4", optional = true } -tree-sitter-graphql = { version = "0.1.0", optional = true } -tree-sitter-html = { version = "0.23.2", optional = true } -tree-sitter-java = { version = "0.23.5", optional = true } -tree-sitter-javascript = { version = "0.23.1", optional = true } -tree-sitter-jsdoc = { version = "0.23.2", optional = true } -tree-sitter-json = "0.24.8" -tree-sitter-kotlin-sg = { version = "0.4.0", optional = true } -tree-sitter-lua = { version = "0.4.1", optional = true } -tree-sitter-make = { version = "1.1.1", optional = true } -tree-sitter-md = { version = "0.5.1", optional = true } -tree-sitter-php = { version = "0.24.2", optional = true } -tree-sitter-proto = { version = "0.2.0", optional = true } -tree-sitter-python = { version = "0.23.6", optional = true } -tree-sitter-ruby = { version = "0.23.1", optional = true } -tree-sitter-rust = { version = "0.24.0", optional = true } -tree-sitter-scala = { version = "0.23.4", optional = true } -tree-sitter-sequel = { version = "0.3.8", optional = true } -tree-sitter-svelte-next = { version = "0.1.1", optional = true } -tree-sitter-swift = { version = "0.7.0", optional = true } -tree-sitter-toml-ng = { version = "0.7.0", optional = true } -tree-sitter-typescript = { version = "0.23.2", optional = true } -tree-sitter-xml = { version = "0.7.0", optional = true } -tree-sitter-yaml = { version = "0.7.1", optional = true } -tree-sitter-zig = { version = "1.1.2", optional = true } - -[target.'cfg(target_os = "macos")'.dependencies] -core-text = "=21.0.0" - -[dev-dependencies] -gpui = { workspace = true, features = ["test-support"] } -indoc = "2" - -[lints] -workspace = true diff --git a/vendor/gpui-component/crates/ui/LICENSE-APACHE b/vendor/gpui-component/crates/ui/LICENSE-APACHE deleted file mode 100644 index 3cc771d2..00000000 --- a/vendor/gpui-component/crates/ui/LICENSE-APACHE +++ /dev/null @@ -1,191 +0,0 @@ -Copyright 2024 - 2025 Longbridge - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-component/crates/ui/build.rs b/vendor/gpui-component/crates/ui/build.rs deleted file mode 100644 index db3280f1..00000000 --- a/vendor/gpui-component/crates/ui/build.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::{fs, path::Path}; - -fn main() { - // Tell Cargo to rerun this build script if any SVG file in assets/icons changes. - let icons_dir = Path::new("../assets/assets/icons"); - - // Watch the icons directory itself. - println!("cargo:rerun-if-changed=../assets/assets/icons"); - - // Watch each SVG file in the directory. - if let Ok(entries) = fs::read_dir(icons_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("svg") { - println!("cargo:rerun-if-changed={}", path.display()); - } - } - } -} diff --git a/vendor/gpui-component/crates/ui/locales/ui.yml b/vendor/gpui-component/crates/ui/locales/ui.yml deleted file mode 100644 index 89637cdd..00000000 --- a/vendor/gpui-component/crates/ui/locales/ui.yml +++ /dev/null @@ -1,257 +0,0 @@ -_version: 2 -Calendar: - week.0: - en: Su - zh-CN: 日 - zh-HK: 日 - it: Do - week.1: - en: Mo - zh-CN: 一 - zh-HK: 一 - it: Lu - week.2: - en: Tu - zh-CN: 二 - zh-HK: 二 - it: Ma - week.3: - en: We - zh-CN: 三 - zh-HK: 三 - it: Me - week.4: - en: Th - zh-CN: 四 - zh-HK: 四 - it: Gi - week.5: - en: Fr - zh-CN: 五 - zh-HK: 五 - it: Ve - week.6: - en: Sa - zh-CN: 六 - zh-HK: 六 - it: Sa - month.January: - en: January - zh-CN: 一月 - zh-HK: 一月 - it: Gennaio - month.February: - en: February - zh-CN: 二月 - zh-HK: 二月 - it: Febbraio - month.March: - en: March - zh-CN: 三月 - zh-HK: 三月 - it: Marzo - month.April: - en: April - zh-CN: 四月 - zh-HK: 四月 - it: Aprile - month.May: - en: May - zh-CN: 五月 - zh-HK: 五月 - it: Maggio - month.June: - en: June - zh-CN: 六月 - zh-HK: 六月 - it: Giugno - month.July: - en: July - zh-CN: 七月 - zh-HK: 七月 - it: Luglio - month.August: - en: August - zh-CN: 八月 - zh-HK: 八月 - it: Agosto - month.September: - en: September - zh-CN: 九月 - zh-HK: 九月 - it: Settembre - month.October: - en: October - zh-CN: 十月 - zh-HK: 十月 - it: Ottobre - month.November: - en: November - zh-CN: 十一月 - zh-HK: 十一月 - it: Novembre - month.December: - en: December - zh-CN: 十二月 - zh-HK: 十二月 - it: Dicembre -DatePicker: - placeholder: - en: "Select date" - zh-CN: 选择日期 - zh-HK: 選擇日期 - it: "Seleziona data" -Select: - placeholder: - en: "Please select" - zh-CN: "请选择" - zh-HK: "請選擇" - it: Seleziona -ComboBox: - placeholder: - en: "Please select" - zh-CN: "请选择" - zh-HK: "請選擇" - it: Seleziona - search_placeholder: - en: "Search..." - zh-CN: "搜索..." - zh-HK: "搜索..." - it: "Cerca..." - empty: - en: "No results" - zh-CN: "暂无数据" - zh-HK: "暫無數據" - it: "Nessun risultato" -Dock: - Unnamed: - en: Unnamed - zh-CN: 未命名 - zh-HK: 未命名 - it: "Senza nome" - Close: - en: Close - zh-CN: 关闭 - zh-HK: 關閉 - it: Chiudi - Zoom In: - en: Zoom In - zh-CN: 放大 - zh-HK: 放大 - it: Zoom In - Zoom Out: - en: Zoom Out - zh-CN: 缩小 - zh-HK: 縮小 - it: Zoom Out - Collapse: - en: Collapse - zh-CN: 隐藏 - zh-HK: 隱藏 - it: Nascondi - Expand: - en: Expand - zh-CN: 展开 - zh-HK: 展開 - it: Espandi -ColorPicker: - Palette: - en: Palette - zh-CN: 调色板 - zh-HK: 調色板 - it: Tavolozza - HSLA: - en: HSLA - zh-CN: HSLA - zh-HK: HSLA - it: HSLA - Hue: - en: Hue - zh-CN: 色相 - zh-HK: 色相 - it: Tonalità - Saturation: - en: Saturation - zh-CN: 饱和度 - zh-HK: 飽和度 - it: Saturazione - Lightness: - en: Lightness - zh-CN: 亮度 - zh-HK: 亮度 - it: Luminosità - Alpha: - en: Alpha - zh-CN: 透明度 - zh-HK: 透明度 - it: Alfa -Dialog: - ok: - en: OK - zh-CN: 确定 - zh-HK: 確定 - it: OK - cancel: - en: Cancel - zh-CN: 取消 - zh-HK: 取消 - it: Annulla -List: - search_placeholder: - en: Search... - zh-CN: 搜索... - zh-HK: 搜索... - it: Ricerca... -Input: - Replace: - en: Replace - zh-CN: 替换 - zh-HK: 替換 - Replace All: - en: Replace All - zh-CN: 全部替换 - zh-HK: 全部替換 - Cut: - en: Cut - zh-CN: 剪切 - zh-HK: 剪切 - Copy: - en: Copy - zh-CN: 复制 - zh-HK: 複製 - Paste: - en: Paste - zh-CN: 粘贴 - zh-HK: 貼上 - Select All: - en: Select All - zh-CN: 全选 - zh-HK: 全選 - Go to Definition: - en: Go to Definition - zh-CN: 跳转到定义 - zh-HK: 跳轉到定義 - Show Code Actions: - en: Show Code Actions - zh-CN: 显示代码操作 - zh-HK: 顯示代碼操作 -Settings: - search_placeholder: - en: Search... - zh-CN: 搜索... - zh-HK: 搜索... - it: Ricerca... - Reset All: - en: Reset All - zh-CN: 重置全部 - zh-HK: 重置全部 - it: Resetta Tutto -Pagination: - previous: - en: Previous - zh-CN: 上一页 - zh-HK: 上一頁 - next: - en: Next - zh-CN: 下一页 - zh-HK: 下一頁 diff --git a/vendor/gpui-component/crates/ui/src/accordion.rs b/vendor/gpui-component/crates/ui/src/accordion.rs deleted file mode 100644 index 248d3e62..00000000 --- a/vendor/gpui-component/crates/ui/src/accordion.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc}; - -use gpui::{ - AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement as _, Styled, Window, div, - prelude::FluentBuilder as _, rems, -}; - -use crate::{ActiveTheme as _, Icon, IconName, Sizable, Size, h_flex, v_flex}; - -/// Accordion element. -#[derive(IntoElement)] -pub struct Accordion { - id: ElementId, - multiple: bool, - size: Size, - bordered: bool, - disabled: bool, - children: Vec, - on_toggle_click: Option>, -} - -impl Accordion { - /// Create a new Accordion with the given ID. - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - multiple: false, - size: Size::default(), - bordered: true, - children: Vec::new(), - disabled: false, - on_toggle_click: None, - } - } - - /// Set whether multiple accordion items can be opened simultaneously, default: false - pub fn multiple(mut self, multiple: bool) -> Self { - self.multiple = multiple; - self - } - - /// Set whether the accordion items have borders, default: true - pub fn bordered(mut self, bordered: bool) -> Self { - self.bordered = bordered; - self - } - - /// Set whether the accordion is disabled, default: false - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - /// Adds an AccordionItem to the Accordion. - pub fn item(mut self, child: F) -> Self - where - F: FnOnce(AccordionItem) -> AccordionItem, - { - let item = child(AccordionItem::new()); - self.children.push(item); - self - } - - /// Sets the on_toggle_click callback for the AccordionGroup. - /// - /// The first argument `Vec` is the indices of the open accordions. - pub fn on_toggle_click( - mut self, - on_toggle_click: impl Fn(&[usize], &mut Window, &mut App) + Send + Sync + 'static, - ) -> Self { - self.on_toggle_click = Some(Arc::new(on_toggle_click)); - self - } -} - -impl Sizable for Accordion { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for Accordion { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let open_ixs = Rc::new(RefCell::new(HashSet::new())); - let is_multiple = self.multiple; - - v_flex() - .id(self.id) - .size_full() - .when(self.bordered, |this| this.gap_y_2()) - .children( - self.children - .into_iter() - .enumerate() - .map(|(ix, accordion)| { - if accordion.open { - open_ixs.borrow_mut().insert(ix); - } - - accordion - .index(ix) - .with_size(self.size) - .bordered(self.bordered) - .disabled(self.disabled) - .on_toggle_click({ - let open_ixs = Rc::clone(&open_ixs); - move |open, _, _| { - let mut open_ixs = open_ixs.borrow_mut(); - if *open { - if !is_multiple { - open_ixs.clear(); - } - open_ixs.insert(ix); - } else { - open_ixs.remove(&ix); - } - } - }) - }), - ) - .when_some( - self.on_toggle_click.filter(|_| !self.disabled), - move |this, on_toggle_click| { - let open_ixs = Rc::clone(&open_ixs); - this.on_click(move |_, window, cx| { - let open_ixs: Vec = open_ixs.borrow().iter().map(|&ix| ix).collect(); - - on_toggle_click(&open_ixs, window, cx); - }) - }, - ) - } -} - -/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it. -#[derive(IntoElement)] -pub struct AccordionItem { - index: usize, - icon: Option, - title: AnyElement, - children: Vec, - open: bool, - size: Size, - bordered: bool, - disabled: bool, - on_toggle_click: Option>, -} - -impl AccordionItem { - /// Create a new AccordionItem. - pub fn new() -> Self { - Self { - index: 0, - icon: None, - title: SharedString::default().into_any_element(), - children: Vec::new(), - open: false, - disabled: false, - on_toggle_click: None, - size: Size::default(), - bordered: true, - } - } - - /// Set the icon for the accordion item. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - /// Set the title for the accordion item. - pub fn title(mut self, title: impl IntoElement) -> Self { - self.title = title.into_any_element(); - self - } - - pub fn bordered(mut self, bordered: bool) -> Self { - self.bordered = bordered; - self - } - - pub fn open(mut self, open: bool) -> Self { - self.open = open; - self - } - - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - fn index(mut self, index: usize) -> Self { - self.index = index; - self - } - - fn on_toggle_click( - mut self, - on_toggle_click: impl Fn(&bool, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_toggle_click = Some(Arc::new(on_toggle_click)); - self - } -} - -impl ParentElement for AccordionItem { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Sizable for AccordionItem { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for AccordionItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let text_size = match self.size { - Size::XSmall => rems(0.875), - Size::Small => rems(0.875), - _ => rems(1.0), - }; - - div().flex_1().child( - v_flex() - .w_full() - .bg(cx.theme().accordion) - .overflow_hidden() - .when(self.bordered, |this| { - this.border_1() - .rounded(cx.theme().radius) - .border_color(cx.theme().border) - }) - .text_size(text_size) - .child( - h_flex() - .id(self.index) - .justify_between() - .gap_3() - .map(|this| match self.size { - Size::XSmall => this.py_0().px_1p5(), - Size::Small => this.py_0p5().px_2(), - Size::Large => this.py_1p5().px_4(), - _ => this.py_1().px_3(), - }) - .when(self.open, |this| { - this.when(self.bordered, |this| { - this.text_color(cx.theme().foreground) - .border_b_1() - .border_color(cx.theme().border) - }) - }) - .when(!self.bordered, |this| { - this.border_b_1().border_color(cx.theme().border) - }) - .child( - h_flex() - .items_center() - .map(|this| match self.size { - Size::XSmall => this.gap_1(), - Size::Small => this.gap_1(), - _ => this.gap_2(), - }) - .when_some(self.icon, |this, icon| { - this.child( - icon.with_size(self.size) - .text_color(cx.theme().muted_foreground), - ) - }) - .child(self.title), - ) - .when(!self.disabled, |this| { - this.hover(|this| this.bg(cx.theme().accordion_hover)) - .child( - Icon::new(if self.open { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .xsmall() - .text_color(cx.theme().muted_foreground), - ) - .when_some(self.on_toggle_click, |this, on_toggle_click| { - this.on_click({ - move |_, window, cx| { - on_toggle_click(&!self.open, window, cx); - } - }) - }) - }), - ) - .when(self.open, |this| { - this.child( - div() - .map(|this| match self.size { - Size::XSmall => this.p_1p5(), - Size::Small => this.p_2(), - Size::Large => this.p_4(), - _ => this.p_3(), - }) - .children(self.children), - ) - }), - ) - } -} diff --git a/vendor/gpui-component/crates/ui/src/actions.rs b/vendor/gpui-component/crates/ui/src/actions.rs deleted file mode 100644 index 71145733..00000000 --- a/vendor/gpui-component/crates/ui/src/actions.rs +++ /dev/null @@ -1,26 +0,0 @@ -use gpui::{Action, actions}; -use serde::Deserialize; - -#[derive(Clone, Action, PartialEq, Eq, Deserialize)] -#[action(namespace = ui, no_json)] -pub struct Confirm { - /// Is confirm with secondary. - pub secondary: bool, -} - -actions!( - ui, - [ - Cancel, - SelectUp, - SelectDown, - SelectLeft, - SelectRight, - SelectFirst, - SelectLast, - SelectPrevColumn, - SelectNextColumn, - SelectPageUp, - SelectPageDown - ] -); diff --git a/vendor/gpui-component/crates/ui/src/alert.rs b/vendor/gpui-component/crates/ui/src/alert.rs deleted file mode 100644 index dab867bf..00000000 --- a/vendor/gpui-component/crates/ui/src/alert.rs +++ /dev/null @@ -1,254 +0,0 @@ -use std::rc::Rc; - -use gpui::{ - App, ClickEvent, ElementId, Empty, Hsla, InteractiveElement, IntoElement, ParentElement as _, - RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, px, rems, transparent_white, -}; - -use crate::{ - ActiveTheme as _, Colorize, Icon, IconName, Sizable, Size, StyledExt, h_flex, - text::{Text, TextViewStyle}, -}; - -/// The variant of the [`Alert`]. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum AlertVariant { - #[default] - Default, - Info, - Success, - Warning, - Error, -} - -impl AlertVariant { - fn fg(&self, cx: &App) -> Hsla { - match self { - Self::Default => cx.theme().foreground, - Self::Info => cx.theme().info, - Self::Success => cx.theme().success, - Self::Warning => cx.theme().warning, - Self::Error => cx.theme().danger, - } - } - - fn bg(&self, cx: &App) -> Hsla { - match self { - Self::Default => cx.theme().background, - Self::Info => cx.theme().info.mix_oklab(transparent_white(), 0.04), - Self::Success => cx.theme().success.mix_oklab(transparent_white(), 0.04), - Self::Warning => cx.theme().warning.mix_oklab(transparent_white(), 0.04), - Self::Error => cx.theme().danger.mix_oklab(transparent_white(), 0.04), - } - } - - fn border_color(&self, cx: &App) -> Hsla { - match self { - Self::Default => cx.theme().border, - Self::Info => cx.theme().info.mix_oklab(transparent_white(), 0.3), - Self::Success => cx.theme().success.mix_oklab(transparent_white(), 0.3), - Self::Warning => cx.theme().warning.mix_oklab(transparent_white(), 0.3), - Self::Error => cx.theme().danger.mix_oklab(transparent_white(), 0.3), - } - } -} - -/// Alert used to display a message to the user. -#[derive(IntoElement)] -pub struct Alert { - id: ElementId, - style: StyleRefinement, - variant: AlertVariant, - icon: Icon, - title: Option, - message: Text, - size: Size, - banner: bool, - on_close: Option>, - visible: bool, -} - -impl Alert { - /// Create a new alert with the given message. - pub fn new(id: impl Into, message: impl Into) -> Self { - Self { - id: id.into(), - style: StyleRefinement::default(), - variant: AlertVariant::default(), - icon: Icon::new(IconName::Info), - title: None, - message: message.into(), - size: Size::default(), - banner: false, - visible: true, - on_close: None, - } - } - - /// Create a new info [`AlertVariant::Info`] with the given message. - pub fn info(id: impl Into, message: impl Into) -> Self { - Self::new(id, message) - .with_variant(AlertVariant::Info) - .icon(IconName::Info) - } - - /// Create a new [`AlertVariant::Success`] alert with the given message. - pub fn success(id: impl Into, message: impl Into) -> Self { - Self::new(id, message) - .with_variant(AlertVariant::Success) - .icon(IconName::CircleCheck) - } - - /// Create a new [`AlertVariant::Warning`] alert with the given message. - pub fn warning(id: impl Into, message: impl Into) -> Self { - Self::new(id, message) - .with_variant(AlertVariant::Warning) - .icon(IconName::TriangleAlert) - } - - /// Create a new [`AlertVariant::Error`] alert with the given message. - pub fn error(id: impl Into, message: impl Into) -> Self { - Self::new(id, message) - .with_variant(AlertVariant::Error) - .icon(IconName::CircleX) - } - - /// Sets the [`AlertVariant`] of the alert. - pub fn with_variant(mut self, variant: AlertVariant) -> Self { - self.variant = variant; - self - } - - /// Set the icon for the alert. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = icon.into(); - self - } - - /// Set the title for the alert. - pub fn title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set alert as banner style. - /// - /// The `banner` style will make the alert take the full width of the container and not border and radius. - /// This mode will not display `title`. - pub fn banner(mut self) -> Self { - self.banner = true; - self - } - - /// Set the visibility of the alert. - pub fn visible(mut self, visible: bool) -> Self { - self.visible = visible; - self - } - - /// Set alert as closable, true will show Close icon. - pub fn on_close( - mut self, - on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_close = Some(Rc::new(on_close)); - self - } -} - -impl Sizable for Alert { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl Styled for Alert { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for Alert { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - if !self.visible { - return Empty.into_any_element(); - } - - let (radius, padding_x, padding_y, gap) = match self.size { - Size::XSmall => (cx.theme().radius, px(12.), px(6.), px(6.)), - Size::Small => (cx.theme().radius, px(12.), px(8.), px(6.)), - Size::Large => (cx.theme().radius_lg, px(20.), px(14.), px(12.)), - _ => (cx.theme().radius, px(16.), px(10.), px(12.)), - }; - - let bg = self.variant.bg(cx); - let fg = self.variant.fg(cx); - let border_color = self.variant.border_color(cx); - - h_flex() - .id(self.id) - .w_full() - .text_color(fg) - .bg(bg) - .px(padding_x) - .py(padding_y) - .gap(gap) - .justify_between() - .text_sm() - .border_1() - .border_color(border_color) - .when(!self.banner, |this| this.rounded(radius).items_start()) - .refine_style(&self.style) - .child( - div() - .flex() - .flex_1() - .when(self.banner, |this| this.items_center()) - .overflow_hidden() - .gap(gap) - .child( - div() - .when(!self.banner, |this| this.mt(px(5.))) - .child(self.icon), - ) - .child( - div() - .flex_1() - .overflow_hidden() - .gap_3() - .when(!self.banner, |this| { - this.when_some(self.title, |this, title| { - this.child( - div().w_full().truncate().font_semibold().child(title), - ) - }) - }) - .child( - self.message - .style(TextViewStyle::default().paragraph_gap(rems(0.2))), - ), - ), - ) - .when_some(self.on_close, |this, on_close| { - this.child( - div() - .id("close") - .p_0p5() - .rounded(cx.theme().radius) - .hover(|this| this.bg(bg.opacity(0.8))) - .active(|this| this.bg(bg.opacity(0.9))) - .on_click(move |ev, window, cx| { - on_close(ev, window, cx); - }) - .child( - Icon::new(IconName::Close) - .with_size(self.size.max(Size::Medium)) - .flex_shrink_0(), - ), - ) - }) - .into_any_element() - } -} diff --git a/vendor/gpui-component/crates/ui/src/animation.rs b/vendor/gpui-component/crates/ui/src/animation.rs deleted file mode 100644 index 25ac6c9a..00000000 --- a/vendor/gpui-component/crates/ui/src/animation.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::{rc::Rc, time::Duration}; - -use gpui::{ - Animation, AnimationExt, ElementId, IntoElement, Pixels, Point, Styled, point, - prelude::FluentBuilder, px, -}; -use smallvec::SmallVec; - -/// A cubic bezier function like CSS `cubic-bezier`. -/// -/// Builder: -/// -/// https://cubic-bezier.com -pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 { - move |t: f32| { - let one_t = 1.0 - t; - let one_t2 = one_t * one_t; - let t2 = t * t; - let t3 = t2 * t; - - // The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1 - let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3; - let y = 3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3; - - y - } -} - -// ── Easing presets ────────────────────────────────────────────────────────── - -/// Cubic ease-out — fast start, slow end. Good for enter animations. -pub fn ease_out_cubic(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - 1.0 - (1.0 - t).powi(3) -} - -/// Cubic ease-in — slow start, fast end. Good for exit animations. -pub fn ease_in_cubic(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - t * t * t -} - -/// Cubic ease-in-out — slow start and end. Good for position transitions. -pub fn ease_in_out_cubic(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - if t < 0.5 { - 4.0 * t * t * t - } else { - 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 - } -} - -// ── Lerp trait ────────────────────────────────────────────────────────────── - -/// Trait for types that support linear interpolation. -pub trait Lerp: Clone { - fn lerp(&self, target: &Self, t: f32) -> Self; -} - -impl Lerp for f32 { - fn lerp(&self, target: &Self, t: f32) -> Self { - self + (target - self) * t - } -} - -impl Lerp for Pixels { - fn lerp(&self, target: &Self, t: f32) -> Self { - let a: f32 = (*self).into(); - let b: f32 = (*target).into(); - px(a + (b - a) * t) - } -} - -impl Lerp for Point { - fn lerp(&self, target: &Self, t: f32) -> Self { - point( - Lerp::lerp(&self.x, &target.x, t), - Lerp::lerp(&self.y, &target.y, t), - ) - } -} - -// ── Transition combinator ─────────────────────────────────────────────────── - -/// A composable transition that describes animated style changes. -/// -/// # Example -/// -/// ```ignore -/// Transition::new(Duration::from_millis(150)) -/// .ease(ease_out_cubic) -/// .slide_y(px(-4.), px(0.)) -/// .fade(0.0, 1.0) -/// .apply(element, "enter-anim") -/// ``` -#[derive(Clone)] -pub struct Transition { - pub duration: Duration, - easing: Rc f32>, - effects: SmallVec<[TransitionEffect; 2]>, -} - -#[derive(Clone, Copy)] -enum TransitionEffect { - SlideY(Pixels, Pixels), - SlideX(Pixels, Pixels), - Fade(f32, f32), - Width(Pixels, Pixels), - Height(Pixels, Pixels), -} - -impl Transition { - pub fn new(duration: Duration) -> Self { - Self { - duration, - easing: Rc::new(ease_out_cubic), - effects: SmallVec::new(), - } - } - - /// Set the easing function. - pub fn ease(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self { - self.easing = Rc::new(easing); - self - } - - /// Animate vertical offset from `from` to `to`. - pub fn slide_y(mut self, from: Pixels, to: Pixels) -> Self { - self.effects.push(TransitionEffect::SlideY(from, to)); - self - } - - /// Animate horizontal offset from `from` to `to`. - pub fn slide_x(mut self, from: Pixels, to: Pixels) -> Self { - self.effects.push(TransitionEffect::SlideX(from, to)); - self - } - - /// Animate opacity from `from` to `to`. - pub fn fade(mut self, from: f32, to: f32) -> Self { - self.effects.push(TransitionEffect::Fade(from, to)); - self - } - - /// Animate width from `from` to `to`. - pub fn width(mut self, from: Pixels, to: Pixels) -> Self { - self.effects.push(TransitionEffect::Width(from, to)); - self - } - - /// Animate height from `from` to `to`. - pub fn height(mut self, from: Pixels, to: Pixels) -> Self { - self.effects.push(TransitionEffect::Height(from, to)); - self - } - - /// Apply this transition to a Styled element, returning an AnimationElement. - pub fn apply( - self, - element: E, - id: impl Into, - ) -> gpui::AnimationElement { - let animation = Animation::new(self.duration).with_easing({ - let easing = self.easing.clone(); - move |t| easing(t) - }); - let effects = self.effects; - element.with_animation(id, animation, move |el, delta| { - let mut el = el; - for effect in &effects { - match effect { - TransitionEffect::SlideY(from, to) => { - el = el.top(Lerp::lerp(from, to, delta)); - } - TransitionEffect::SlideX(from, to) => { - el = el.left(Lerp::lerp(from, to, delta)); - } - TransitionEffect::Fade(from, to) => { - el = el.opacity(Lerp::lerp(from, to, delta)); - } - TransitionEffect::Width(from, to) => { - el = el.w(Lerp::lerp(from, to, delta)); - } - TransitionEffect::Height(from, to) => { - el = el.h(Lerp::lerp(from, to, delta)); - } - } - } - el - }) - } -} - -impl FluentBuilder for Transition {} diff --git a/vendor/gpui-component/crates/ui/src/async_util.rs b/vendor/gpui-component/crates/ui/src/async_util.rs deleted file mode 100644 index ed4fe1c2..00000000 --- a/vendor/gpui-component/crates/ui/src/async_util.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Cross-platform async utilities for both native and WASM targets. - -// For native targets, re-export smol's primitives -#[cfg(not(target_family = "wasm"))] -pub use smol::channel::{Receiver, Sender, unbounded}; - -// For WASM targets, use async-channel -#[cfg(target_family = "wasm")] -pub use async_channel::{Receiver, Sender, unbounded}; diff --git a/vendor/gpui-component/crates/ui/src/avatar/avatar.rs b/vendor/gpui-component/crates/ui/src/avatar/avatar.rs deleted file mode 100644 index 521cbafb..00000000 --- a/vendor/gpui-component/crates/ui/src/avatar/avatar.rs +++ /dev/null @@ -1,168 +0,0 @@ -use gpui::{ - App, Div, Hsla, ImageSource, InteractiveElement, Interactivity, IntoElement, - ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, img, - prelude::FluentBuilder, -}; - -use crate::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, Size, StyledExt, - avatar::{AvatarSized as _, avatar_size}, -}; - -/// User avatar element. -/// -/// We can use [`Sizable`] trait to set the size of the avatar (see also: [`avatar_size`] about the size in pixels). -#[derive(IntoElement)] -pub struct Avatar { - base: Div, - style: StyleRefinement, - src: Option, - name: Option, - short_name: SharedString, - placeholder: Icon, - size: Size, -} - -impl Avatar { - pub fn new() -> Self { - Self { - base: div(), - style: StyleRefinement::default(), - src: None, - name: None, - short_name: SharedString::default(), - placeholder: Icon::new(IconName::User), - size: Size::Medium, - } - } - - /// Set to use image source for the avatar. - pub fn src(mut self, source: impl Into) -> Self { - self.src = Some(source.into()); - self - } - - /// Set name of the avatar user, if `src` is none, will use this name as placeholder. - pub fn name(mut self, name: impl Into) -> Self { - let name: SharedString = name.into(); - let short: SharedString = extract_text_initials(&name).into(); - - self.name = Some(name); - self.short_name = short; - self - } - - /// Set placeholder icon, default: [`IconName::User`] - pub fn placeholder(mut self, icon: impl Into) -> Self { - self.placeholder = icon.into(); - self - } -} - -impl Sizable for Avatar { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl Styled for Avatar { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl InteractiveElement for Avatar { - fn interactivity(&mut self) -> &mut Interactivity { - self.base.interactivity() - } -} - -impl RenderOnce for Avatar { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let corner_radii = self.style.corner_radii.clone(); - let mut inner_style = StyleRefinement::default(); - inner_style.corner_radii = corner_radii; - - const COLOR_COUNT: u64 = 360 / 15; - fn default_color(ix: u64, cx: &mut App) -> Hsla { - let h = (ix * 15).clamp(0, 360) as f32; - cx.theme().blue.hue(h / 360.0) - } - - const BG_OPACITY: f32 = 0.2; - - self.base - .avatar_size(self.size) - .flex() - .items_center() - .justify_center() - .flex_shrink_0() - .rounded_full() - .overflow_hidden() - .bg(cx.theme().secondary) - .text_color(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .when(self.name.is_none() && self.src.is_none(), |this| { - this.text_size(avatar_size(self.size) * 0.6) - .child(self.placeholder) - }) - .map(|this| match self.src { - None => this.when(self.name.is_some(), |this| { - let color_ix = gpui::hash(&self.short_name) % COLOR_COUNT; - let color = default_color(color_ix, cx); - - this.bg(color.opacity(BG_OPACITY)) - .text_color(color) - .child(div().avatar_text_size(self.size).child(self.short_name)) - }), - Some(src) => this.child( - img(src) - .avatar_size(self.size) - .rounded_full() - .refine_style(&inner_style), - ), - }) - .refine_style(&self.style) - } -} - -fn extract_text_initials(text: &str) -> String { - let mut result = text - .split(" ") - .flat_map(|word| word.chars().next().map(|c| c.to_string())) - .take(2) - .collect::>() - .join(""); - - if result.len() == 1 { - result = text.chars().take(2).collect::(); - } - - result.to_uppercase() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_avatar_text_initials() { - assert_eq!(extract_text_initials(&"Jason Lee"), "JL".to_string()); - assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string()); - assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string()); - } - - #[gpui::test] - fn test_avatar_builder(_cx: &mut gpui::TestAppContext) { - let avatar = Avatar::new() - .name("Jason Lee") - .placeholder(Icon::new(IconName::User)) - .large(); - - assert_eq!(avatar.name, Some(SharedString::from("Jason Lee"))); - assert_eq!(avatar.short_name, SharedString::from("JL")); - assert_eq!(avatar.size, Size::Large); - } -} diff --git a/vendor/gpui-component/crates/ui/src/avatar/avatar_group.rs b/vendor/gpui-component/crates/ui/src/avatar/avatar_group.rs deleted file mode 100644 index 6bfdec12..00000000 --- a/vendor/gpui-component/crates/ui/src/avatar/avatar_group.rs +++ /dev/null @@ -1,131 +0,0 @@ -use gpui::{ - Div, InteractiveElement, Interactivity, IntoElement, ParentElement as _, RenderOnce, - StyleRefinement, Styled, div, prelude::FluentBuilder as _, -}; - -use crate::{ActiveTheme, Sizable, Size, StyledExt as _, avatar::Avatar}; - -/// A grouped avatars to display in a compact layout. -#[derive(IntoElement)] -pub struct AvatarGroup { - base: Div, - style: StyleRefinement, - avatars: Vec, - size: Size, - limit: usize, - ellipsis: bool, -} - -impl AvatarGroup { - /// Create a new AvatarGroup. - pub fn new() -> Self { - Self { - base: div(), - style: StyleRefinement::default(), - avatars: Vec::new(), - size: Size::default(), - limit: 3, - ellipsis: false, - } - } - - /// Add a child avatar to the group. - pub fn child(mut self, avatar: Avatar) -> Self { - self.avatars.push(avatar); - self - } - - /// Add multiple child avatars to the group. - pub fn children(mut self, avatars: impl IntoIterator) -> Self { - self.avatars.extend(avatars); - self - } - - /// Set the maximum number of avatars to display before showing a "more" avatar. - pub fn limit(mut self, limit: usize) -> Self { - self.limit = limit; - self - } - - /// Set whether to show an ellipsis when the limit is reached, default: false - pub fn ellipsis(mut self) -> Self { - self.ellipsis = true; - self - } -} - -impl Sizable for AvatarGroup { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl Styled for AvatarGroup { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl InteractiveElement for AvatarGroup { - fn interactivity(&mut self) -> &mut Interactivity { - self.base.interactivity() - } -} - -impl RenderOnce for AvatarGroup { - fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { - let item_ml = -super::avatar_size(self.size) * 0.3; - let avatars_len = self.avatars.len(); - - self.base - .h_flex() - .flex_row_reverse() - .refine_style(&self.style) - .children(if self.ellipsis && avatars_len > self.limit { - Some( - Avatar::new() - .name("⋯") - .bg(cx.theme().secondary) - .text_color(cx.theme().muted_foreground) - .with_size(self.size) - .ml_1(), - ) - } else { - None - }) - .children( - self.avatars - .into_iter() - .take(self.limit) - .enumerate() - .rev() - .map(|(ix, item)| { - item.with_size(self.size) - .when(ix > 0, |this| this.ml(item_ml)) - }), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[gpui::test] - fn test_avatar_group_builder(_cx: &mut gpui::TestAppContext) { - let group = AvatarGroup::new() - .child(Avatar::new().name("Alice")) - .child(Avatar::new().name("Bob")) - .child(Avatar::new().name("Charlie")) - .child(Avatar::new().name("David")) - .large() - .limit(3) - .ellipsis(); - - assert_eq!(group.avatars.len(), 4); - assert_eq!(group.size, Size::Large); - assert_eq!(group.limit, 3); - assert!(group.ellipsis); - } -} diff --git a/vendor/gpui-component/crates/ui/src/avatar/mod.rs b/vendor/gpui-component/crates/ui/src/avatar/mod.rs deleted file mode 100644 index d9e29ba1..00000000 --- a/vendor/gpui-component/crates/ui/src/avatar/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -mod avatar; -mod avatar_group; - -pub use avatar::*; -pub use avatar_group::*; - -use crate::{Icon, Size, StyledExt as _}; -use gpui::{Div, Img, IntoElement, Pixels, Styled, px, rems}; - -/// Returns the size of the avatar based on the given [`Size`]. -pub(super) fn avatar_size(size: Size) -> Pixels { - match size { - Size::Large => px(80.), - Size::Medium => px(48.), - Size::Small => px(24.), - Size::XSmall => px(16.), - Size::Size(size) => size, - } -} - -/// Extension for add `avatar_size` method to `IntoElement` to apply avatar size to element. -pub(super) trait AvatarSized: IntoElement + Styled { - fn avatar_size(self, size: Size) -> Self { - self.size(avatar_size(size)) - } - - fn avatar_text_size(self, size: Size) -> Self { - match size { - Size::Large => self.text_3xl().font_semibold(), - Size::Medium => self.text_sm(), - Size::Small => self.text_xs(), - Size::XSmall => self.text_size(rems(0.65)), - Size::Size(size) => self.size(size * 0.5), - } - } -} -impl AvatarSized for Div {} -impl AvatarSized for Icon {} -impl AvatarSized for Img {} diff --git a/vendor/gpui-component/crates/ui/src/badge.rs b/vendor/gpui-component/crates/ui/src/badge.rs deleted file mode 100644 index d4b7ee86..00000000 --- a/vendor/gpui-component/crates/ui/src/badge.rs +++ /dev/null @@ -1,165 +0,0 @@ -use gpui::{ - AnyElement, App, Hsla, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, - div, prelude::FluentBuilder, px, relative, -}; - -use crate::{ActiveTheme, Icon, Sizable, Size, StyledExt, h_flex, white}; - -#[derive(Default, Clone)] -enum BadgeVariant { - #[default] - Number, - Dot, - Icon(Box), -} - -#[allow(unused)] -impl BadgeVariant { - #[inline] - fn is_icon(&self) -> bool { - matches!(self, BadgeVariant::Icon(_)) - } - - #[inline] - fn is_number(&self) -> bool { - matches!(self, BadgeVariant::Number) - } -} - -/// A badge for displaying a count, dot, or icon on an element. -#[derive(IntoElement)] -pub struct Badge { - style: StyleRefinement, - count: usize, - max: usize, - variant: BadgeVariant, - children: Vec, - color: Option, - size: Size, -} - -impl Badge { - /// Create a new badge. - pub fn new() -> Self { - Self { - style: StyleRefinement::default(), - count: 0, - max: 99, - variant: Default::default(), - color: None, - children: Vec::new(), - size: Size::default(), - } - } - - /// Set to use [`BadgeVariant::Dot`] to show a dot. - pub fn dot(mut self) -> Self { - self.variant = BadgeVariant::Dot; - self - } - - /// Set to use [`BadgeVariant::Number`] to show a count. - /// - /// If count is 0, the badge will be hidden. - pub fn count(mut self, count: usize) -> Self { - self.count = count; - self - } - - /// Set to use [`BadgeVariant::Icon`] to show an icon. - pub fn icon(mut self, icon: impl Into) -> Self { - self.variant = BadgeVariant::Icon(Box::new(icon.into())); - self - } - - /// Set the maximum count to show (Only if [`BadgeVariant::Number`] is used). - pub fn max(mut self, max: usize) -> Self { - self.max = max; - self - } - - /// Set the color (background) of the badge. - pub fn color(mut self, color: impl Into) -> Self { - self.color = Some(color.into()); - self - } -} - -impl ParentElement for Badge { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Sizable for Badge { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for Badge { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let visible = match self.variant { - BadgeVariant::Number => self.count > 0, - BadgeVariant::Dot | BadgeVariant::Icon(_) => true, - }; - - let (size, text_size) = match self.size { - Size::Large => (px(24.), px(14.)), - Size::Medium | Size::Size(_) => (px(16.), px(10.)), - Size::Small | Size::XSmall => (px(10.), px(8.)), - }; - - div() - .relative() - .refine_style(&self.style) - .children(self.children) - .when(visible, |this| { - this.child( - h_flex() - .absolute() - .justify_center() - .items_center() - .rounded_full() - .bg(self.color.unwrap_or(cx.theme().red)) - .text_color(white()) - .text_size(text_size) - .map(|this| match self.variant { - BadgeVariant::Dot => this.top_0().right_0().size(px(6.)), - BadgeVariant::Number => { - let count = if self.count > self.max { - format!("{}+", self.max) - } else { - self.count.to_string() - }; - - let (top, left) = match self.size { - Size::Large => (px(2.), -px(count.len() as f32)), - Size::Medium | Size::Size(_) => { - (-px(3.), -px(3.) * count.len()) - } - Size::Small | Size::XSmall => (-px(4.), -px(4.) * count.len()), - }; - - this.top(top) - .right(left) - .py_0p5() - .px_0p5() - .min_w_3p5() - .text_size(px(10.)) - .line_height(relative(1.)) - .child(count) - } - BadgeVariant::Icon(icon) => this - .right_0() - .bottom_0() - .size(size) - .border_1() - .border_color(cx.theme().background) - .child(*icon), - }), - ) - }) - } -} diff --git a/vendor/gpui-component/crates/ui/src/breadcrumb.rs b/vendor/gpui-component/crates/ui/src/breadcrumb.rs deleted file mode 100644 index 2d0ec046..00000000 --- a/vendor/gpui-component/crates/ui/src/breadcrumb.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::rc::Rc; - -use gpui::{ - App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, -}; - -use crate::{ActiveTheme, Icon, IconName, StyledExt, h_flex}; - -/// A breadcrumb navigation element. -#[derive(IntoElement)] -pub struct Breadcrumb { - style: StyleRefinement, - items: Vec, -} - -/// Item for the [`Breadcrumb`]. -#[derive(IntoElement)] -pub struct BreadcrumbItem { - id: ElementId, - style: StyleRefinement, - label: SharedString, - on_click: Option>, - disabled: bool, - is_last: bool, -} - -impl BreadcrumbItem { - /// Create a new BreadcrumbItem with the given id and label. - pub fn new(label: impl Into) -> Self { - Self { - id: ElementId::Integer(0), - style: StyleRefinement::default(), - label: label.into(), - on_click: None, - disabled: false, - is_last: false, - } - } - - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - pub fn on_click( - mut self, - on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_click = Some(Rc::new(on_click)); - self - } - - fn id(mut self, id: impl Into) -> Self { - self.id = id.into(); - self - } - - /// For internal use only. - fn is_last(mut self, is_last: bool) -> Self { - self.is_last = is_last; - self - } -} - -impl Styled for BreadcrumbItem { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl From<&'static str> for BreadcrumbItem { - fn from(value: &'static str) -> Self { - Self::new(value) - } -} - -impl From for BreadcrumbItem { - fn from(value: String) -> Self { - Self::new(value) - } -} - -impl From for BreadcrumbItem { - fn from(value: SharedString) -> Self { - Self::new(value) - } -} - -impl RenderOnce for BreadcrumbItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - div() - .id(self.id) - .child(self.label) - .text_color(cx.theme().muted_foreground) - .when(self.is_last, |this| this.text_color(cx.theme().foreground)) - .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) - }) - .refine_style(&self.style) - .when(!self.disabled, |this| { - this.when_some(self.on_click, |this, on_click| { - this.cursor_pointer().on_click(move |event, window, cx| { - on_click(event, window, cx); - }) - }) - }) - } -} - -impl Breadcrumb { - /// Create a new breadcrumb. - pub fn new() -> Self { - Self { - items: Vec::new(), - style: StyleRefinement::default(), - } - } - - /// Add an [`BreadcrumbItem`] to the breadcrumb. - pub fn child(mut self, item: impl Into) -> Self { - self.items.push(item.into()); - self - } - - /// Add multiple [`BreadcrumbItem`] items to the breadcrumb. - pub fn children(mut self, items: impl IntoIterator>) -> Self { - self.items.extend(items.into_iter().map(Into::into)); - self - } -} - -#[derive(IntoElement)] -struct BreadcrumbSeparator; -impl RenderOnce for BreadcrumbSeparator { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - Icon::new(IconName::ChevronRight) - .text_color(cx.theme().muted_foreground) - .size_3p5() - .into_any_element() - } -} - -impl Styled for Breadcrumb { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for Breadcrumb { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let items_count = self.items.len(); - - let mut children = vec![]; - for (ix, item) in self.items.into_iter().enumerate() { - let is_last = ix == items_count - 1; - - let item = item.id(ix); - children.push(item.is_last(is_last).into_any_element()); - if !is_last { - children.push(BreadcrumbSeparator.into_any_element()); - } - } - - h_flex() - .gap_1p5() - .text_sm() - .text_color(cx.theme().muted_foreground) - .refine_style(&self.style) - .children(children) - } -} diff --git a/vendor/gpui-component/crates/ui/src/button/button.rs b/vendor/gpui-component/crates/ui/src/button/button.rs deleted file mode 100644 index d1a6b865..00000000 --- a/vendor/gpui-component/crates/ui/src/button/button.rs +++ /dev/null @@ -1,1027 +0,0 @@ -use std::rc::Rc; - -use crate::{ - ActiveTheme, Colorize as _, Disableable, FocusableExt as _, Icon, IconName, Selectable, - Sizable, Size, StyleSized, StyledExt, - button::ButtonIcon, - h_flex, - tooltip::{ManagedTooltipExt as _, Tooltip}, -}; -use gpui::{ - AnyElement, App, ClickEvent, Corners, Div, Edges, ElementId, Hsla, InteractiveElement, - Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, - Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, px, relative, transparent_white, -}; - -#[derive(Default, Clone, Copy)] -pub enum ButtonRounded { - None, - Small, - #[default] - Medium, - Large, - Size(Pixels), -} - -impl From for ButtonRounded { - fn from(px: Pixels) -> Self { - ButtonRounded::Size(px) - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct ButtonCustomVariant { - color: Hsla, - foreground: Hsla, - shadow: bool, - hover: Hsla, - active: Hsla, -} - -pub trait ButtonVariants: Sized { - fn with_variant(self, variant: ButtonVariant) -> Self; - - /// With the primary style for the Button. - fn primary(self) -> Self { - self.with_variant(ButtonVariant::Primary) - } - - /// With the secondary style for the Button. - fn secondary(self) -> Self { - self.with_variant(ButtonVariant::Secondary) - } - - /// With the danger style for the Button. - fn danger(self) -> Self { - self.with_variant(ButtonVariant::Danger) - } - - /// With the warning style for the Button. - fn warning(self) -> Self { - self.with_variant(ButtonVariant::Warning) - } - - /// With the success style for the Button. - fn success(self) -> Self { - self.with_variant(ButtonVariant::Success) - } - - /// With the info style for the Button. - fn info(self) -> Self { - self.with_variant(ButtonVariant::Info) - } - - /// With the ghost style for the Button. - fn ghost(self) -> Self { - self.with_variant(ButtonVariant::Ghost) - } - - /// With the link style for the Button. - fn link(self) -> Self { - self.with_variant(ButtonVariant::Link) - } - - /// With the text style for the Button, it will no padding look like a normal text. - fn text(self) -> Self { - self.with_variant(ButtonVariant::Text) - } - - /// With the custom style for the Button. - fn custom(self, style: ButtonCustomVariant) -> Self { - self.with_variant(ButtonVariant::Custom(style)) - } -} - -impl ButtonCustomVariant { - pub fn new(cx: &App) -> Self { - Self { - color: cx.theme().transparent, - foreground: cx.theme().foreground, - hover: cx.theme().transparent, - active: cx.theme().transparent, - shadow: false, - } - } - - /// Set background color, default is transparent. - pub fn color(mut self, color: Hsla) -> Self { - self.color = color; - self - } - - /// Set foreground color, default is theme foreground. - pub fn foreground(mut self, color: Hsla) -> Self { - self.foreground = color; - self - } - - /// Set hover background color, default is transparent. - pub fn hover(mut self, color: Hsla) -> Self { - self.hover = color; - self - } - - /// Set active background color, default is transparent. - pub fn active(mut self, color: Hsla) -> Self { - self.active = color; - self - } - - /// Set shadow, default is false. - pub fn shadow(mut self, shadow: bool) -> Self { - self.shadow = shadow; - self - } -} - -/// The variant of the Button. -#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] -pub enum ButtonVariant { - #[default] - Default, - Primary, - Secondary, - Danger, - Info, - Success, - Warning, - Ghost, - Link, - Text, - Custom(ButtonCustomVariant), -} - -impl ButtonVariant { - #[inline] - pub fn is_link(&self) -> bool { - matches!(self, Self::Link) - } - - #[inline] - pub fn is_text(&self) -> bool { - matches!(self, Self::Text) - } - - #[inline] - pub fn is_ghost(&self) -> bool { - matches!(self, Self::Ghost) - } - - #[inline] - fn no_padding(&self) -> bool { - self.is_link() || self.is_text() - } - - #[inline] - fn is_default(&self) -> bool { - matches!(self, Self::Default) - } -} - -/// A Button element. -#[derive(IntoElement)] -pub struct Button { - id: ElementId, - base: Stateful
, - style: StyleRefinement, - icon: Option, - label: Option, - children: Vec, - disabled: bool, - pub(crate) selected: bool, - variant: ButtonVariant, - rounded: ButtonRounded, - outline: bool, - border_corners: Corners, - border_edges: Edges, - dropdown_caret: bool, - size: Size, - compact: bool, - tooltip: Option<( - SharedString, - Option<(Rc>, Option)>, - )>, - tooltip_builder: Option gpui::AnyView>>, - on_click: Option>, - on_hover: Option>, - loading: bool, - loading_icon: Option, - - tab_index: isize, - tab_stop: bool, -} - -impl From", - "

Some text

", - false, - false, - ), - ] { - let mut w = vec![]; - let mut minifier = Minifier::new(&mut w); - minifier - .omit_doctype(true) - .collapse_whitespace(collapse_whitespace) - .preserve_comments(preserve_comments); - minifier.minify(&mut input.as_bytes()).unwrap(); - - let s = str::from_utf8(&w).unwrap(); - - assert_eq!(expected, s); - } - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/format/markdown.rs b/vendor/gpui-component/crates/ui/src/text/format/markdown.rs deleted file mode 100644 index 0f5f90e3..00000000 --- a/vendor/gpui-component/crates/ui/src/text/format/markdown.rs +++ /dev/null @@ -1,538 +0,0 @@ -use std::ops::Range; - -use gpui::SharedString; -use markdown::{ - ParseOptions, - mdast::{self, Node}, -}; - -use crate::{ - highlighter::HighlightTheme, - text::{ - document::ParsedDocument, - node::{ - self, BlockNode, CodeBlock, ImageNode, InlineNode, LinkMark, NodeContext, Paragraph, - Span, Table, TableRow, TextMark, - }, - }, -}; - -/// Parse Markdown into a tree of nodes. -/// -/// TODO: Remove `highlight_theme` option, this should in render stage. -pub(crate) fn parse( - source: &str, - cx: &mut NodeContext, - highlight_theme: &HighlightTheme, -) -> Result { - markdown::to_mdast(&source, &ParseOptions::gfm()) - .map(|n| ast_to_document(source, n, cx, highlight_theme)) - .map_err(|e| e.to_string().into()) -} - -fn parse_table_row(table: &mut Table, node: &mdast::TableRow, cx: &mut NodeContext) { - let mut row = TableRow::default(); - node.children.iter().for_each(|c| { - match c { - Node::TableCell(cell) => { - parse_table_cell(&mut row, cell, cx); - } - _ => {} - }; - }); - table.children.push(row); -} - -fn parse_table_cell(row: &mut node::TableRow, node: &mdast::TableCell, cx: &mut NodeContext) { - let mut paragraph = Paragraph::default(); - node.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - let table_cell = node::TableCell { - children: paragraph, - ..Default::default() - }; - row.children.push(table_cell); -} - -/// Push a text run with its existing `marks` plus `new_mark` across the full -/// run. -/// -/// If the last mark already covers the full run, merge into it. Otherwise add a -/// new full-run mark. Empty runs are skipped so callers can flush freely. -fn push_merged( - paragraph: &mut Paragraph, - text: String, - marks: Vec<(Range, TextMark)>, - new_mark: TextMark, -) { - if text.is_empty() { - return; - } - - let mut node = InlineNode::new(text).marks(marks); - let len = node.text.len(); - if let Some(last) = node.marks.last_mut() - && last.0.start == 0 - && last.0.end == len - { - last.1.merge(new_mark); - } else { - node.marks.push((0..len, new_mark)); - } - paragraph.push(node); -} - -/// Parse `children` and apply `mark` across each emitted text run. -/// -/// Nested child marks are kept and shifted to match the combined text for the -/// current run, which lets nested emphasis like `**_x_**` render as both bold -/// and italic. Inline images split the run and are emitted as sibling image -/// nodes. The return value is the plain text from all children, for callers that -/// need to pass text back to their parent node. -fn merge_children_with_mark( - paragraph: &mut Paragraph, - children: &[mdast::Node], - mark: TextMark, - cx: &mut NodeContext, -) -> String { - let mut text = String::new(); - let mut merged_text = String::new(); - let mut merged_marks = Vec::new(); - - for child in children { - let mut child_paragraph = Paragraph::default(); - let child_text = parse_paragraph(&mut child_paragraph, child, cx); - text.push_str(&child_text); - - for node in child_paragraph.children { - let merged_offset = merged_text.len(); - merged_text.push_str(&node.text); - - for (range, child_mark) in node.marks { - merged_marks.push(( - range.start + merged_offset..range.end + merged_offset, - child_mark, - )); - } - - if let Some(mut image) = node.image { - if let Some(link_mark) = mark.link.clone() { - image.link = Some(link_mark); - } - - // GPUI InteractiveText does not support inline images, so - // flush the accumulated text run and emit the image as its - // own sibling InlineNode. - push_merged( - paragraph, - std::mem::take(&mut merged_text), - std::mem::take(&mut merged_marks), - mark.clone(), - ); - paragraph.push(InlineNode::image(image)); - } - } - } - - push_merged(paragraph, merged_text, merged_marks, mark); - text -} - -fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node, cx: &mut NodeContext) -> String { - let span = node.position().map(|pos| Span { - start: cx.offset + pos.start.offset, - end: cx.offset + pos.end.offset, - }); - if let Some(span) = span { - paragraph.set_span(span); - } - - let mut text = String::new(); - - match node { - Node::Paragraph(val) => { - val.children.iter().for_each(|c| { - text.push_str(&parse_paragraph(paragraph, c, cx)); - }); - } - Node::Text(val) => { - text = val.value.clone(); - paragraph.push_str(&val.value) - } - Node::Emphasis(val) => { - text = merge_children_with_mark( - paragraph, - &val.children, - TextMark::default().italic(), - cx, - ); - } - Node::Strong(val) => { - text = - merge_children_with_mark(paragraph, &val.children, TextMark::default().bold(), cx); - } - Node::Delete(val) => { - text = merge_children_with_mark( - paragraph, - &val.children, - TextMark::default().strikethrough(), - cx, - ); - } - Node::InlineCode(val) => { - text = val.value.clone(); - paragraph.push( - InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default().code())]), - ); - } - Node::Link(val) => { - let link_mark = Some(LinkMark { - url: val.url.clone().into(), - title: val.title.clone().map(|s| s.into()), - ..Default::default() - }); - - text = merge_children_with_mark( - paragraph, - &val.children, - TextMark { - link: link_mark, - ..Default::default() - }, - cx, - ); - } - Node::Image(raw) => { - paragraph.push_image(ImageNode { - url: raw.url.clone().into(), - title: raw.title.clone().map(|t| t.into()), - alt: Some(raw.alt.clone().into()), - ..Default::default() - }); - } - Node::InlineMath(raw) => { - text = raw.value.clone(); - paragraph.push( - InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default().code())]), - ); - } - Node::MdxTextExpression(raw) => { - text = raw.value.clone(); - paragraph - .push(InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default())])); - } - Node::Html(val) => match super::html::parse(&val.value, cx) { - Ok(el) => { - if el - .blocks - .first() - .map(|node| node.is_break()) - .unwrap_or(false) - { - text = "\n".to_owned(); - paragraph.push(InlineNode::new(&text)); - } else { - if cfg!(debug_assertions) { - tracing::warn!("unsupported inline html tag: {:#?}", el); - } - } - } - Err(err) => { - if cfg!(debug_assertions) { - tracing::warn!("failed parsing html: {:#?}", err); - } - - text.push_str(&val.value); - } - }, - Node::FootnoteReference(foot) => { - let prefix = format!("[{}]", foot.identifier); - paragraph.push(InlineNode::new(&prefix).marks(vec![( - 0..prefix.len(), - TextMark { - italic: true, - ..Default::default() - }, - )])); - } - Node::LinkReference(link) => { - let link_mark = LinkMark { - url: "".into(), - title: link.label.clone().map(Into::into), - identifier: Some(link.identifier.clone().into()), - }; - - text = merge_children_with_mark( - paragraph, - &link.children, - TextMark { - link: Some(link_mark), - ..Default::default() - }, - cx, - ); - } - _ => { - if cfg!(debug_assertions) { - tracing::warn!("unsupported inline node: {:#?}", node); - } - } - } - - text -} - -fn ast_to_document( - source: &str, - root: mdast::Node, - cx: &mut NodeContext, - highlight_theme: &HighlightTheme, -) -> ParsedDocument { - let root = match root { - Node::Root(r) => r, - _ => panic!("expected root node"), - }; - - let blocks = root - .children - .into_iter() - .map(|c| ast_to_node(c, cx, highlight_theme)) - .collect(); - ParsedDocument { - source: source.to_string().into(), - blocks, - } -} - -fn new_span(pos: Option, cx: &NodeContext) -> Option { - let pos = pos?; - - Some(Span { - start: cx.offset + pos.start.offset, - end: cx.offset + pos.end.offset, - }) -} - -fn ast_to_node( - value: mdast::Node, - cx: &mut NodeContext, - highlight_theme: &HighlightTheme, -) -> BlockNode { - match value { - Node::Root(_) => unreachable!("node::Root should be handled separately"), - Node::Paragraph(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - paragraph.span = new_span(val.position, cx); - BlockNode::Paragraph(paragraph) - } - Node::Blockquote(val) => { - let children = val - .children - .into_iter() - .map(|c| ast_to_node(c, cx, highlight_theme)) - .collect(); - BlockNode::Blockquote { - children, - span: new_span(val.position, cx), - } - } - Node::List(list) => { - let children = list - .children - .into_iter() - .map(|c| ast_to_node(c, cx, highlight_theme)) - .collect(); - BlockNode::List { - ordered: list.ordered, - children, - span: new_span(list.position, cx), - } - } - Node::ListItem(val) => { - let children = val - .children - .into_iter() - .map(|c| ast_to_node(c, cx, highlight_theme)) - .collect(); - BlockNode::ListItem { - children, - spread: val.spread, - checked: val.checked, - span: new_span(val.position, cx), - } - } - Node::Break(val) => BlockNode::Break { - html: false, - span: new_span(val.position, cx), - }, - Node::Code(raw) => BlockNode::CodeBlock(CodeBlock::new( - raw.value.into(), - raw.lang.map(|s| s.into()), - highlight_theme, - new_span(raw.position, cx), - )), - Node::Heading(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - - BlockNode::Heading { - level: val.depth, - children: paragraph, - span: new_span(val.position, cx), - } - } - Node::Math(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - None, - highlight_theme, - new_span(val.position, cx), - )), - Node::Html(val) => match super::html::parse(&val.value, cx) { - Ok(el) => BlockNode::Root { - children: el.blocks, - span: new_span(val.position, cx), - }, - Err(err) => { - if cfg!(debug_assertions) { - tracing::warn!("error parsing html: {:#?}", err); - } - - BlockNode::Paragraph(Paragraph::new(val.value)) - } - }, - Node::MdxFlowExpression(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - Some("mdx".into()), - highlight_theme, - new_span(val.position, cx), - )), - Node::Yaml(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - Some("yml".into()), - highlight_theme, - new_span(val.position, cx), - )), - Node::Toml(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - Some("toml".into()), - highlight_theme, - new_span(val.position, cx), - )), - Node::MdxJsxTextElement(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - paragraph.span = new_span(val.position, cx); - BlockNode::Paragraph(paragraph) - } - Node::MdxJsxFlowElement(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - paragraph.span = new_span(val.position, cx); - BlockNode::Paragraph(paragraph) - } - Node::ThematicBreak(val) => BlockNode::HorizontalRule { - span: new_span(val.position, cx), - }, - Node::Table(val) => { - let mut table = Table::default(); - table.column_aligns = val - .align - .clone() - .into_iter() - .map(|align| align.into()) - .collect(); - val.children.iter().for_each(|c| { - if let Node::TableRow(row) = c { - parse_table_row(&mut table, row, cx); - } - }); - table.span = new_span(val.position, cx); - - BlockNode::Table(table) - } - Node::FootnoteDefinition(def) => { - let mut paragraph = Paragraph::default(); - let prefix = format!("[{}]: ", def.identifier); - paragraph.push(InlineNode::new(&prefix).marks(vec![( - 0..prefix.len(), - TextMark { - italic: true, - ..Default::default() - }, - )])); - - def.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c, cx); - }); - paragraph.span = new_span(def.position, cx); - BlockNode::Paragraph(paragraph) - } - Node::Definition(def) => { - cx.add_ref( - def.identifier.clone().into(), - LinkMark { - url: def.url.clone().into(), - identifier: Some(def.identifier.clone().into()), - title: def.title.clone().map(Into::into), - }, - ); - - BlockNode::Definition { - identifier: def.identifier.clone().into(), - url: def.url.clone().into(), - title: def.title.clone().map(|s| s.into()), - span: new_span(def.position, cx), - } - } - _ => { - if cfg!(debug_assertions) { - tracing::warn!("unsupported node: {:#?}", value); - } - BlockNode::Unknown - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_nested_emphasis_merges_text_marks() { - let mut cx = NodeContext::default(); - let document = parse( - "This has **_bold and italic_** text.", - &mut cx, - &HighlightTheme::default_light(), - ) - .unwrap(); - - let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { - panic!("expected paragraph"); - }; - - let bold_italic = paragraph - .children - .iter() - .find(|child| child.text.as_ref() == "bold and italic") - .expect("expected emphasized text"); - - assert!( - bold_italic - .marks - .iter() - .any(|(_, mark)| mark.bold && mark.italic), - "nested emphasis should produce a bold and italic mark" - ); - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/format/mod.rs b/vendor/gpui-component/crates/ui/src/text/format/mod.rs deleted file mode 100644 index 6a742007..00000000 --- a/vendor/gpui-component/crates/ui/src/text/format/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(super) mod html; -mod html5minify; -pub(super) mod markdown; diff --git a/vendor/gpui-component/crates/ui/src/text/inline.rs b/vendor/gpui-component/crates/ui/src/text/inline.rs deleted file mode 100644 index a7095a00..00000000 --- a/vendor/gpui-component/crates/ui/src/text/inline.rs +++ /dev/null @@ -1,670 +0,0 @@ -use gpui::Corners; -use std::{ - ops::Range, - rc::Rc, - sync::{Arc, Mutex}, -}; - -use gpui::{ - App, BorderStyle, Bounds, CursorStyle, Edges, Element, ElementId, GlobalElementId, Half, - HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, - MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, StyledText, TextLayout, Window, - point, px, quad, -}; - -use crate::{ActiveTheme, global_state::GlobalState, input::Selection, text::node::LinkMark}; - -/// A inline element used to render a inline text and support selectable. -/// -/// All text in TextView (including the CodeBlock) used this for text rendering. -pub(super) struct Inline { - id: ElementId, - text: SharedString, - links: Rc, LinkMark)>>, - highlights: Vec<(Range, HighlightStyle)>, - styled_text: StyledText, - - state: Arc>, -} - -/// The inline text state, used RefCell to keep the selection state. -#[derive(Debug, Default, PartialEq)] -pub(crate) struct InlineState { - hovered_index: Option, - /// The text that actually rendering, matched with selection. - pub(super) text: SharedString, - pub(super) selection: Option, -} - -impl InlineState { - /// Save actually rendered text for selected text to use. - pub(crate) fn set_text(&mut self, text: SharedString) { - self.text = text; - } -} - -impl Inline { - pub(super) fn new( - id: impl Into, - state: Arc>, - links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, - ) -> Self { - let text = state.lock().unwrap().text.clone(); - Self { - id: id.into(), - links: Rc::new(links), - highlights, - text: text.clone(), - styled_text: StyledText::new(text), - state, - } - } - - /// Get link at given mouse position. - fn link_for_position( - layout: &TextLayout, - links: &Vec<(Range, LinkMark)>, - position: Point, - ) -> Option { - let offset = layout.index_for_position(position).ok()?; - for (range, link) in links.iter() { - if range.contains(&offset) { - return Some(link.clone()); - } - } - - None - } - - /// Paint selected bounds for debug. - #[allow(unused)] - fn paint_selected_bounds(&self, bounds: Bounds, window: &mut Window, cx: &mut App) { - window.paint_quad(gpui::PaintQuad { - bounds, - background: cx.theme().blue.alpha(0.01).into(), - corner_radii: Corners::default(), - border_color: gpui::transparent_black(), - border_style: BorderStyle::default(), - border_widths: gpui::Edges::all(px(0.)), - }); - } - - fn layout_selections( - &self, - text_layout: &TextLayout, - window: &mut Window, - cx: &mut App, - ) -> (bool, bool, Option) { - let Some(text_view_state) = GlobalState::global(cx).text_view_state() else { - return (false, false, None); - }; - - let text_view_state = text_view_state.read(cx); - let is_selectable = text_view_state.is_selectable(); - if !text_view_state.has_selection() { - return (is_selectable, false, None); - } - - let Some((selection_start, selection_end)) = text_view_state.selection_points() else { - return (is_selectable, false, None); - }; - let line_height = window.line_height(); - - // Use for debug selection bounds - // self.paint_selected_bounds(Bounds::from_corners(selection_start, selection_end), window, cx); - - let mut selection: Option = None; - let mut offset = 0; - let mut chars = self.text.chars().peekable(); - while let Some(c) = chars.next() { - let Some(pos) = text_layout.position_for_index(offset) else { - offset += c.len_utf8(); - continue; - }; - - let mut char_width = line_height.half(); - if let Some(next_pos) = text_layout.position_for_index(offset + 1) { - if next_pos.y == pos.y { - char_width = next_pos.x - pos.x; - } - } - - if point_in_text_selection(pos, char_width, selection_start, selection_end, line_height) - { - if selection.is_none() { - selection = Some((offset..offset).into()); - } - - let next_offset = offset + c.len_utf8(); - selection.as_mut().unwrap().end = next_offset; - } - - offset += c.len_utf8(); - } - - (true, true, selection) - } - - /// Paint the selection background. - fn paint_selection( - selection: &Selection, - text_layout: &TextLayout, - bounds: &Bounds, - window: &mut Window, - cx: &mut App, - ) { - let mut start = selection.start; - let mut end = selection.end; - if end < start { - std::mem::swap(&mut start, &mut end); - } - let Some(start_position) = text_layout.position_for_index(start) else { - return; - }; - let Some(end_position) = text_layout.position_for_index(end) else { - return; - }; - - let line_height = text_layout.line_height(); - if start_position.y == end_position.y { - window.paint_quad(quad( - Bounds::from_corners( - start_position, - point(end_position.x, end_position.y + line_height), - ), - px(0.), - cx.theme().selection, - Edges::default(), - gpui::transparent_black(), - BorderStyle::default(), - )); - } else { - window.paint_quad(quad( - Bounds::from_corners( - start_position, - point(bounds.right(), start_position.y + line_height), - ), - px(0.), - cx.theme().selection, - Edges::default(), - gpui::transparent_black(), - BorderStyle::default(), - )); - - if end_position.y > start_position.y + line_height { - window.paint_quad(quad( - Bounds::from_corners( - point(bounds.left(), start_position.y + line_height), - point(bounds.right(), end_position.y), - ), - px(0.), - cx.theme().selection, - Edges::default(), - gpui::transparent_black(), - BorderStyle::default(), - )); - } - - window.paint_quad(quad( - Bounds::from_corners( - point(bounds.left(), end_position.y), - point(end_position.x, end_position.y + line_height), - ), - px(0.), - cx.theme().selection, - Edges::default(), - gpui::transparent_black(), - BorderStyle::default(), - )); - } - } -} - -impl IntoElement for Inline { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for Inline { - type RequestLayoutState = (); - type PrepaintState = Hitbox; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - global_element_id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let text_style = window.text_style(); - - let mut runs = Vec::new(); - let mut ix = 0; - for (range, highlight) in self.highlights.iter() { - if ix < range.start { - runs.push(text_style.clone().to_run(range.start - ix)); - } - runs.push(text_style.clone().highlight(*highlight).to_run(range.len())); - ix = range.end; - } - if ix < self.text.len() { - runs.push(text_style.to_run(self.text.len() - ix)); - } - - self.styled_text = StyledText::new(self.text.clone()).with_runs(runs); - let (layout_id, _) = - self.styled_text - .request_layout(global_element_id, inspector_id, window, cx); - - (layout_id, ()) - } - - fn prepaint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - self.styled_text - .prepaint(id, inspector_id, bounds, &mut (), window, cx); - - let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); - hitbox - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: Bounds, - _: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let current_view = window.current_view(); - let hitbox = prepaint; - let mut state = self.state.lock().unwrap(); - - let text_layout = self.styled_text.layout().clone(); - self.styled_text - .paint(global_id, None, bounds, &mut (), &mut (), window, cx); - - // layout selections - let (is_selectable, is_selection, selection) = - self.layout_selections(&text_layout, window, cx); - - state.selection = selection; - - if is_selection || is_selectable { - window.set_cursor_style(CursorStyle::IBeam, &hitbox); - } - - // link cursor pointer - let mouse_position = window.mouse_position(); - if let Some(_) = Self::link_for_position(&text_layout, &self.links, mouse_position) { - window.set_cursor_style(CursorStyle::PointingHand, &hitbox); - } - - if let Some(selection) = &state.selection { - Self::paint_selection(selection, &text_layout, &bounds, window, cx); - } - - // mouse move, update hovered link - window.on_mouse_event({ - let hitbox = hitbox.clone(); - let text_layout = text_layout.clone(); - let mut hovered_index = state.hovered_index; - move |event: &MouseMoveEvent, phase, window, cx| { - if !phase.bubble() || !hitbox.is_hovered(window) { - return; - } - - let current = hovered_index; - let updated = text_layout.index_for_position(event.position).ok(); - // notify update when hovering over different links - if current != updated { - hovered_index = updated; - cx.notify(current_view); - } - } - }); - - if !is_selection { - // click to open link - window.on_mouse_event({ - let links = self.links.clone(); - let text_layout = text_layout.clone(); - let hitbox = hitbox.clone(); - - move |event: &MouseUpEvent, phase, window, cx| { - if !phase.bubble() || !hitbox.is_hovered(window) { - return; - } - - if let Some(link) = - Self::link_for_position(&text_layout, &links, event.position) - { - cx.stop_propagation(); - cx.open_url(&link.url); - } - } - }); - } - } -} - -/// Check if a `pos` is within a `bounds`, considering multi-line selections. -fn point_in_text_selection( - pos: Point, - char_width: Pixels, - selection_start: Point, - selection_end: Point, - line_height: Pixels, -) -> bool { - let point_in_line = |point: Point| point.y >= pos.y && point.y < pos.y + line_height; - let top = selection_start.y.min(selection_end.y); - let bottom = selection_start.y.max(selection_end.y); - let x = pos.x + char_width.half(); - - // Out of the vertical bounds - if pos.y + line_height <= top || pos.y > bottom { - return false; - } - - // Treat the selection as single-line when both drag points fall within the - // same rendered line, even if their y coordinates differ inside that line. - if point_in_line(selection_start) && point_in_line(selection_end) { - let left = selection_start.x.min(selection_end.x); - let right = selection_start.x.max(selection_end.x); - return x >= left && x <= right; - } - - let (top_point, bottom_point) = if selection_start.y < selection_end.y { - (selection_start, selection_end) - } else { - (selection_end, selection_start) - }; - let is_top_line = point_in_line(top_point); - let is_bottom_line = point_in_line(bottom_point); - - if is_top_line { - return x >= top_point.x; - } else if is_bottom_line { - return x <= bottom_point.x; - } else { - return true; - } -} - -#[cfg(test)] -mod tests { - use super::point_in_text_selection; - use gpui::{point, px}; - - #[test] - fn test_point_in_text_selection() { - let line_height = px(20.); - let char_width = px(10.); - let start = point(px(50.), px(50.)); - let end = point(px(150.), px(150.)); - - // First line but haft line height, true - // | p --------| - // | selection | - // |-----------| - assert!(point_in_text_selection( - point(px(50.), px(40.)), - char_width, - start, - end, - line_height - )); - - // First line in selection, true - // | p --------| - // | selection | - // |-----------| - assert!(point_in_text_selection( - point(px(50.), px(50.)), - char_width, - start, - end, - line_height - )); - // First line, but left out of selection, false - // p |-----------| - // | selection | - // |-----------| - assert!(!point_in_text_selection( - point(px(40.), px(50.)), - char_width, - start, - end, - line_height - )); - // First line but right out of selection, true - // |-----------| p - // | selection | - // |-----------| - assert!(point_in_text_selection( - point(px(160.), px(50.)), - char_width, - start, - end, - line_height - )); - - // Middle line in selection, true - // |-----------| - // | p | - // |-----------| - assert!(point_in_text_selection( - point(px(100.), px(70.)), - char_width, - start, - end, - line_height - )); - // Middle line, but left out of selection, true - // |-----------| - // p | selection | - // |-----------| - assert!(point_in_text_selection( - point(px(40.), px(70.)), - char_width, - start, - end, - line_height - )); - // Middle line, but right out of selection, true - // |-----------| - // | selection | p - // |-----------| - assert!(point_in_text_selection( - point(px(160.), px(70.)), - char_width, - start, - end, - line_height - )); - - // Last line in selection, true - // |-----------| - // | selection | - // |------- p -| - assert!(point_in_text_selection( - point(px(100.), px(140.)), - char_width, - start, - end, - line_height - )); - // Last line, but left out of selection, true - // - // |-----------| - // | selection | - // p |-----------| - assert!(point_in_text_selection( - point(px(40.), px(140.)), - char_width, - start, - end, - line_height - )); - // Last line, but right out of selection, false - // |-----------| - // | selection | - // |-----------| p - assert!(!point_in_text_selection( - point(px(160.), px(140.)), - char_width, - start, - end, - line_height - )); - - // Out of vertical bounds (top), false - // p - // |-----------| - // | selection | - // |-----------| - assert!(!point_in_text_selection( - point(px(100.), px(20.)), - char_width, - start, - end, - line_height - )); - // Out of vertical bounds (bottom), false - // |-----------| - // | selection | - // |-----------| - // p - assert!(!point_in_text_selection( - point(px(100.), px(160.)), - char_width, - start, - end, - line_height - )); - } - - #[test] - fn test_point_in_text_selection_reversed_drag_direction() { - let line_height = px(20.); - let char_width = px(10.); - - // Mouse down on lower line then drag upward to x=150. - // Top line should follow current mouse x, bottom line should keep anchor x. - let start = point(px(80.), px(150.)); - let end = point(px(150.), px(50.)); - - // On top line, selection starts from top cursor x (150), so x=140 should be excluded. - assert!(!point_in_text_selection( - point(px(140.), px(50.)), - char_width, - start, - end, - line_height - )); - assert!(point_in_text_selection( - point(px(150.), px(50.)), - char_width, - start, - end, - line_height - )); - - // On bottom line, selection ends at anchor x (80), so x=90 should be excluded. - assert!(point_in_text_selection( - point(px(75.), px(140.)), - char_width, - start, - end, - line_height - )); - assert!(!point_in_text_selection( - point(px(80.), px(140.)), - char_width, - start, - end, - line_height - )); - } - - #[test] - fn test_point_in_text_selection_same_visual_line_with_different_y() { - let line_height = px(20.); - let char_width = px(10.); - let start = point(px(100.), px(55.)); - let end = point(px(60.), px(58.)); - - assert!(!point_in_text_selection( - point(px(40.), px(50.)), - char_width, - start, - end, - line_height - )); - assert!(point_in_text_selection( - point(px(70.), px(50.)), - char_width, - start, - end, - line_height - )); - assert!(!point_in_text_selection( - point(px(110.), px(50.)), - char_width, - start, - end, - line_height - )); - } - - #[test] - fn test_point_in_text_selection_same_visual_line_with_reversed_y() { - let line_height = px(20.); - let char_width = px(10.); - let start = point(px(60.), px(58.)); - let end = point(px(100.), px(55.)); - - assert!(!point_in_text_selection( - point(px(40.), px(50.)), - char_width, - start, - end, - line_height - )); - assert!(point_in_text_selection( - point(px(70.), px(50.)), - char_width, - start, - end, - line_height - )); - assert!(!point_in_text_selection( - point(px(110.), px(50.)), - char_width, - start, - end, - line_height - )); - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/mod.rs b/vendor/gpui-component/crates/ui/src/text/mod.rs deleted file mode 100644 index 4d560363..00000000 --- a/vendor/gpui-component/crates/ui/src/text/mod.rs +++ /dev/null @@ -1,96 +0,0 @@ -mod document; -mod format; -mod inline; -mod node; -mod state; -mod style; -mod text_view; -mod utils; - -use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; -pub use state::*; -pub use style::*; -pub use text_view::*; - -pub(crate) fn init(cx: &mut App) { - state::init(cx); -} - -/// Create a new markdown text view with code location as id. -#[track_caller] -pub fn markdown(source: impl Into) -> TextView { - let id: ElementId = ElementId::CodeLocation(*std::panic::Location::caller()); - TextView::markdown(id, source) -} - -/// Create a new html text view with code location as id. -#[track_caller] -pub fn html(source: impl Into) -> TextView { - let id: ElementId = ElementId::CodeLocation(*std::panic::Location::caller()); - TextView::html(id, source) -} - -#[derive(IntoElement, Clone)] -pub enum Text { - String(SharedString), - TextView(Box), -} - -impl From for Text { - fn from(s: SharedString) -> Self { - Self::String(s) - } -} - -impl From<&str> for Text { - fn from(s: &str) -> Self { - Self::String(SharedString::from(s.to_string())) - } -} - -impl From for Text { - fn from(s: String) -> Self { - Self::String(s.into()) - } -} - -impl From for Text { - fn from(e: TextView) -> Self { - Self::TextView(Box::new(e)) - } -} - -impl Text { - /// Set the style for [`TextView`]. - /// - /// Do nothing if this is `String`. - pub fn style(self, style: TextViewStyle) -> Self { - match self { - Self::String(s) => Self::String(s), - Self::TextView(e) => Self::TextView(Box::new(e.style(style))), - } - } - - /// Get the text content. - pub(crate) fn get_text(&self, cx: &App) -> SharedString { - match self { - Self::String(s) => s.clone(), - Self::TextView(view) => { - if let Some(state) = &view.state { - state.read(cx).source() - } else { - SharedString::default() - } - } - } - } -} - -impl RenderOnce for Text { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - match self { - Self::String(s) => s.into_any_element(), - Self::TextView(e) => e.into_any_element(), - } - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/node.rs b/vendor/gpui-component/crates/ui/src/text/node.rs deleted file mode 100644 index eb5d7c31..00000000 --- a/vendor/gpui-component/crates/ui/src/text/node.rs +++ /dev/null @@ -1,1288 +0,0 @@ -use std::{ - collections::HashMap, - ops::Range, - sync::{Arc, Mutex}, -}; - -use gpui::{ - AnyElement, App, DefiniteLength, Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, - InteractiveElement as _, IntoElement, Length, ObjectFit, ParentElement, SharedString, - SharedUri, StatefulInteractiveElement, Styled, StyledImage as _, Window, div, img, - prelude::FluentBuilder as _, px, relative, rems, -}; -use markdown::mdast; -use ropey::Rope; - -use crate::{ - ActiveTheme as _, Icon, IconName, StyledExt, h_flex, - highlighter::{HighlightTheme, SyntaxHighlighter}, - text::{ - CodeBlockActionsFn, - document::NodeRenderOptions, - inline::{Inline, InlineState}, - }, - tooltip::Tooltip, - v_flex, -}; - -use super::{TextViewStyle, utils::list_item_prefix}; - -/// The block-level nodes. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum BlockNode { - /// Something like a Div container in HTML. - Root { - children: Vec, - span: Option, - }, - Paragraph(Paragraph), - Heading { - level: u8, - children: Paragraph, - span: Option, - }, - Blockquote { - children: Vec, - span: Option, - }, - List { - /// Only contains ListItem, others will be ignored - children: Vec, - ordered: bool, - span: Option, - }, - ListItem { - children: Vec, - spread: bool, - /// Whether the list item is checked, if None, it's not a checkbox - checked: Option, - span: Option, - }, - CodeBlock(CodeBlock), - Table(Table), - Break { - html: bool, - span: Option, - }, - HorizontalRule { - span: Option, - }, - /// Use for to_markdown get raw definition - Definition { - identifier: SharedString, - url: SharedString, - title: Option, - span: Option, - }, - Unknown, -} - -impl BlockNode { - pub(super) fn is_list_item(&self) -> bool { - matches!(self, Self::ListItem { .. }) - } - - pub(super) fn is_break(&self) -> bool { - matches!(self, Self::Break { .. }) - } - - /// Combine all children, omitting the empt parent nodes. - pub(super) fn compact(self) -> BlockNode { - match self { - Self::Root { mut children, .. } if children.len() == 1 => children.remove(0).compact(), - _ => self, - } - } - - /// Get the span of the node. - pub(super) fn span(&self) -> Option { - match self { - BlockNode::Root { span, .. } => *span, - BlockNode::Paragraph(paragraph) => paragraph.span, - BlockNode::Heading { span, .. } => *span, - BlockNode::Blockquote { span, .. } => *span, - BlockNode::List { span, .. } => *span, - BlockNode::ListItem { span, .. } => *span, - BlockNode::CodeBlock(code_block) => code_block.span, - BlockNode::Table(table) => table.span, - BlockNode::Break { span, .. } => *span, - BlockNode::HorizontalRule { span, .. } => *span, - BlockNode::Definition { span, .. } => *span, - BlockNode::Unknown { .. } => None, - } - } - - pub(super) fn selected_text(&self) -> String { - let mut text = String::new(); - match self { - BlockNode::Root { children, .. } => { - let mut block_text = String::new(); - for c in children.iter() { - block_text.push_str(&c.selected_text()); - } - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::Paragraph(paragraph) => { - let mut block_text = String::new(); - block_text.push_str(¶graph.selected_text()); - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::Heading { children, .. } => { - let mut block_text = String::new(); - block_text.push_str(&children.selected_text()); - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::List { children, .. } => { - for c in children.iter() { - text.push_str(&c.selected_text()); - } - } - BlockNode::ListItem { children, .. } => { - for c in children.iter() { - text.push_str(&c.selected_text()); - } - } - BlockNode::Blockquote { children, .. } => { - let mut block_text = String::new(); - for c in children.iter() { - block_text.push_str(&c.selected_text()); - } - - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::Table(table) => { - let mut block_text = String::new(); - for row in table.children.iter() { - let mut row_texts = vec![]; - for cell in row.children.iter() { - row_texts.push(cell.children.selected_text()); - } - if !row_texts.is_empty() { - block_text.push_str(&row_texts.join(" ")); - block_text.push('\n'); - } - } - - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::CodeBlock(code_block) => { - let block_text = code_block.selected_text(); - if !block_text.is_empty() { - text.push_str(&block_text); - text.push('\n'); - } - } - BlockNode::Definition { .. } - | BlockNode::Break { .. } - | BlockNode::HorizontalRule { .. } - | BlockNode::Unknown { .. } => {} - } - - text - } -} - -#[allow(unused)] -#[derive(Debug, Default, Clone, PartialEq)] -pub struct LinkMark { - pub url: SharedString, - /// Optional identifier for footnotes. - pub identifier: Option, - pub title: Option, -} - -#[derive(Debug, Default, Clone, PartialEq)] -pub struct TextMark { - pub bold: bool, - pub italic: bool, - pub strikethrough: bool, - pub underline: bool, - pub code: bool, - pub link: Option, -} - -impl TextMark { - pub fn bold(mut self) -> Self { - self.bold = true; - self - } - - pub fn italic(mut self) -> Self { - self.italic = true; - self - } - - pub fn strikethrough(mut self) -> Self { - self.strikethrough = true; - self - } - - pub fn underline(mut self) -> Self { - self.underline = true; - self - } - - pub fn code(mut self) -> Self { - self.code = true; - self - } - - pub fn link(mut self, link: impl Into) -> Self { - self.link = Some(link.into()); - self - } - - pub fn merge(&mut self, other: TextMark) { - self.bold |= other.bold; - self.italic |= other.italic; - self.strikethrough |= other.strikethrough; - self.underline |= other.underline; - self.code |= other.code; - if let Some(link) = other.link { - self.link = Some(link); - } - } -} - -/// The bytes -#[derive(Debug, Default, Copy, Clone, PartialEq)] -pub struct Span { - pub start: usize, - pub end: usize, -} - -impl From for ElementId { - fn from(value: Span) -> Self { - ElementId::Name(format!("md-{}:{}", value.start, value.end).into()) - } -} - -#[allow(unused)] -#[derive(Debug, Default, Clone)] -pub struct ImageNode { - pub url: SharedUri, - pub link: Option, - pub title: Option, - pub alt: Option, - pub width: Option, - pub height: Option, -} - -impl ImageNode { - pub fn title(&self) -> String { - self.title - .clone() - .unwrap_or_else(|| self.alt.clone().unwrap_or_default()) - .to_string() - } -} - -impl PartialEq for ImageNode { - fn eq(&self, other: &Self) -> bool { - self.url == other.url - && self.link == other.link - && self.title == other.title - && self.alt == other.alt - && self.width == other.width - && self.height == other.height - } -} - -#[derive(Default, Clone, Debug)] -pub(crate) struct InlineNode { - /// The text content. - pub(crate) text: SharedString, - pub(crate) image: Option, - /// The text styles, each tuple contains the range of the text and the style. - pub(crate) marks: Vec<(Range, TextMark)>, - - state: Arc>, -} - -impl PartialEq for InlineNode { - fn eq(&self, other: &Self) -> bool { - self.text == other.text && self.image == other.image && self.marks == other.marks - } -} - -impl InlineNode { - pub(crate) fn new(text: impl Into) -> Self { - Self { - text: text.into(), - image: None, - marks: vec![], - state: Arc::new(Mutex::new(InlineState::default())), - } - } - - pub(crate) fn image(image: ImageNode) -> Self { - let mut this = Self::new(""); - this.image = Some(image); - this - } - - pub(crate) fn marks(mut self, marks: Vec<(Range, TextMark)>) -> Self { - self.marks = marks; - self - } -} - -/// The paragraph element, contains multiple text nodes. -/// -/// Unlike other Element, this is cloneable, because it is used in the Node AST. -/// We are keep the selection state inside this AST Nodes. -#[derive(Debug, Clone, Default)] -pub(crate) struct Paragraph { - pub(super) span: Option, - pub(super) children: Vec, - /// The link references in this paragraph, used for reference links. - /// - /// The key is the identifier, the value is the url. - pub(super) link_refs: HashMap, - - pub(crate) state: Arc>, -} - -impl PartialEq for Paragraph { - fn eq(&self, other: &Self) -> bool { - self.span == other.span - && self.children == other.children - && self.link_refs == other.link_refs - } -} - -impl Paragraph { - pub(crate) fn new(text: String) -> Self { - Self { - span: None, - children: vec![InlineNode::new(&text)], - link_refs: HashMap::new(), - state: Arc::new(Mutex::new(InlineState::default())), - } - } - - pub(super) fn selected_text(&self) -> String { - let mut text = String::new(); - - for c in self.children.iter() { - let state = c.state.lock().unwrap(); - if let Some(selection) = &state.selection { - let part_text = state.text.clone(); - text.push_str(&part_text[selection.start..selection.end]); - } - } - - let state = self.state.lock().unwrap(); - if let Some(selection) = &state.selection { - let all_text = state.text.clone(); - text.push_str(&all_text[selection.start..selection.end]); - } - - text - } -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub(crate) struct Table { - pub(crate) children: Vec, - pub(crate) column_aligns: Vec, - pub(crate) span: Option, -} - -impl Table { - pub(crate) fn column_align(&self, index: usize) -> ColumnumnAlign { - self.column_aligns.get(index).copied().unwrap_or_default() - } -} - -#[derive(Debug, Default, Copy, Clone, PartialEq)] -pub(crate) enum ColumnumnAlign { - #[default] - Left, - Center, - Right, -} - -impl From for ColumnumnAlign { - fn from(value: mdast::AlignKind) -> Self { - match value { - mdast::AlignKind::None => ColumnumnAlign::Left, - mdast::AlignKind::Left => ColumnumnAlign::Left, - mdast::AlignKind::Center => ColumnumnAlign::Center, - mdast::AlignKind::Right => ColumnumnAlign::Right, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub(crate) struct TableRow { - pub children: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub(crate) struct TableCell { - pub children: Paragraph, - pub width: Option, -} - -impl Paragraph { - pub(crate) fn take(&mut self) -> Paragraph { - std::mem::replace( - self, - Paragraph { - span: None, - children: vec![], - link_refs: Default::default(), - state: Arc::new(Mutex::new(InlineState::default())), - }, - ) - } - - pub(crate) fn is_image(&self) -> bool { - false - } - - pub(crate) fn set_span(&mut self, span: Span) { - self.span = Some(span); - } - - pub(crate) fn push_str(&mut self, text: &str) { - self.children.push( - InlineNode::new(text.to_string()).marks(vec![(0..text.len(), TextMark::default())]), - ); - } - - pub(crate) fn push(&mut self, text: InlineNode) { - self.children.push(text); - } - - pub(crate) fn push_image(&mut self, image: ImageNode) { - self.children.push(InlineNode::image(image)); - } - - pub(crate) fn is_empty(&self) -> bool { - self.children.is_empty() - || self - .children - .iter() - .all(|node| node.text.is_empty() && node.image.is_none()) - } - - /// Return length of children text. - pub(crate) fn text_len(&self) -> usize { - self.children - .iter() - .map(|node| node.text.len()) - .sum::() - } - - pub(crate) fn merge(&mut self, other: Self) { - self.children.extend(other.children); - } -} - -#[derive(Debug, Clone)] -pub struct CodeBlock { - lang: Option, - styles: Vec<(Range, HighlightStyle)>, - state: Arc>, - pub span: Option, -} - -impl PartialEq for CodeBlock { - fn eq(&self, other: &Self) -> bool { - self.lang == other.lang && self.styles == other.styles - } -} - -impl CodeBlock { - /// Get the language of the code block. - pub fn lang(&self) -> Option { - self.lang.clone() - } - - /// Get the code content of the code block. - pub fn code(&self) -> SharedString { - self.state.lock().unwrap().text.clone() - } - - pub(crate) fn new( - code: SharedString, - lang: Option, - highlight_theme: &HighlightTheme, - span: Option>, - ) -> Self { - let mut styles = vec![]; - if let Some(lang) = &lang { - let mut highlighter = SyntaxHighlighter::new(&lang); - highlighter.update(None, &Rope::from_str(code.as_str()), None); - styles = highlighter.styles(&(0..code.len()), highlight_theme); - }; - - let state = Arc::new(Mutex::new(InlineState::default())); - state.lock().unwrap().set_text(code); - - Self { - lang, - styles, - state, - span: span.map(|s| s.into()), - } - } - - pub(super) fn selected_text(&self) -> String { - let mut text = String::new(); - let state = self.state.lock().unwrap(); - if let Some(selection) = &state.selection { - let part_text = state.text.clone(); - text.push_str(&part_text[selection.start..selection.end]); - } - text - } - - fn render( - &self, - options: &NodeRenderOptions, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - let style = &node_cx.style; - - div() - .when(!options.is_last, |this| this.pb(style.paragraph_gap)) - .child( - div() - .id(("codeblock", options.ix)) - .p_3() - .rounded(cx.theme().radius) - .bg(cx.theme().muted) - .font_family(cx.theme().mono_font_family.clone()) - .text_size(cx.theme().mono_font_size) - .relative() - .refine_style(&style.code_block) - .child(Inline::new( - "code", - self.state.clone(), - vec![], - self.styles.clone(), - )) - .when_some(node_cx.code_block_actions.clone(), |this, actions| { - this.child( - div() - .id("actions") - .absolute() - .top_2() - .right_2() - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .child(actions(&self, window, cx)), - ) - }), - ) - .into_any_element() - } -} - -/// A context for rendering nodes, contains link references. -#[derive(Default, Clone)] -pub(crate) struct NodeContext { - /// The byte offset of the node in the original markdown text. - /// Used for incremental updates. - pub(crate) offset: usize, - pub(crate) link_refs: HashMap, - pub(crate) style: TextViewStyle, - pub(crate) code_block_actions: Option>, -} - -impl NodeContext { - pub(super) fn add_ref(&mut self, identifier: SharedString, link: LinkMark) { - self.link_refs.insert(identifier, link); - } -} - -impl PartialEq for NodeContext { - fn eq(&self, other: &Self) -> bool { - self.link_refs == other.link_refs && self.style == other.style - // Note: code_block_buttons is intentionally not compared (closures can't be compared) - } -} - -impl Paragraph { - fn render( - &self, - node_cx: &NodeContext, - _window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - let span = self.span; - let children = &self.children; - - let mut child_nodes: Vec = vec![]; - - let mut text = String::new(); - let mut highlights: Vec<(Range, HighlightStyle)> = vec![]; - let mut links: Vec<(Range, LinkMark)> = vec![]; - let mut offset = 0; - - let mut ix = 0; - for inline_node in children { - let text_len = inline_node.text.len(); - text.push_str(&inline_node.text); - - if let Some(image) = &inline_node.image { - if text.len() > 0 { - inline_node - .state - .lock() - .unwrap() - .set_text(text.clone().into()); - child_nodes.push( - Inline::new( - ix, - inline_node.state.clone(), - links.clone(), - highlights.clone(), - ) - .into_any_element(), - ); - } - child_nodes.push( - img(image.url.clone()) - .id(ix) - .object_fit(ObjectFit::Contain) - .max_w(relative(1.)) - .when_some(image.width, |this, width| this.w(width)) - .when_some(image.link.clone(), |this, link| { - let title = image.title(); - this.cursor_pointer() - .tooltip(move |window, cx| { - Tooltip::new(title.clone()).build(window, cx) - }) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.open_url(&link.url); - }) - }) - .into_any_element(), - ); - - text.clear(); - links.clear(); - highlights.clear(); - offset = 0; - } else { - let mut node_highlights = vec![]; - for (range, style) in &inline_node.marks { - let inner_range = (offset + range.start)..(offset + range.end); - - let mut highlight = HighlightStyle::default(); - if style.bold { - highlight.font_weight = Some(FontWeight::BOLD); - } - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - if style.strikethrough { - highlight.strikethrough = Some(gpui::StrikethroughStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.underline { - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.code { - highlight.background_color = Some(cx.theme().accent); - } - - if let Some(mut link_mark) = style.link.clone() { - highlight.color = Some(cx.theme().link); - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - - // convert link references, replace link - if let Some(identifier) = link_mark.identifier.as_ref() { - if let Some(mark) = node_cx.link_refs.get(identifier) { - link_mark = mark.clone(); - } - } - - links.push((inner_range.clone(), link_mark)); - } - - node_highlights.push((inner_range, highlight)); - } - - highlights = gpui::combine_highlights(highlights, node_highlights).collect(); - offset += text_len; - } - ix += 1; - } - - // Add the last text node - if text.len() > 0 { - self.state.lock().unwrap().set_text(text.into()); - child_nodes - .push(Inline::new(ix, self.state.clone(), links, highlights).into_any_element()); - } - - div().id(span.unwrap_or_default()).children(child_nodes) - } -} - -impl Paragraph { - fn to_markdown(&self) -> String { - let mut text = self - .children - .iter() - .map(|text_node| { - let mut text = text_node.text.to_string(); - for (range, style) in &text_node.marks { - if style.bold { - text = format!("**{}**", &text_node.text[range.clone()]); - } - if style.italic { - text = format!("*{}*", &text_node.text[range.clone()]); - } - if style.strikethrough { - text = format!("~~{}~~", &text_node.text[range.clone()]); - } - if style.code { - text = format!("`{}`", &text_node.text[range.clone()]); - } - if let Some(link) = &style.link { - text = format!("[{}]({})", &text_node.text[range.clone()], link.url); - } - } - - if let Some(image) = &text_node.image { - let alt = image.alt.clone().unwrap_or_default(); - let title = image - .title - .clone() - .map_or(String::new(), |t| format!(" \"{}\"", t)); - text.push_str(&format!("![{}]({}{})", alt, image.url, title)) - } - - text - }) - .collect::>() - .join(""); - - text.push_str("\n\n"); - text - } -} - -impl BlockNode { - /// Converts the node to markdown format. - /// - /// This is used to generate markdown for test. - #[allow(dead_code)] - pub(crate) fn to_markdown(&self) -> String { - match self { - BlockNode::Root { children, .. } => children - .iter() - .map(|child| child.to_markdown()) - .collect::>() - .join("\n\n"), - BlockNode::Paragraph(paragraph) => paragraph.to_markdown(), - BlockNode::Heading { - level, children, .. - } => { - let hashes = "#".repeat(*level as usize); - format!("{} {}", hashes, children.to_markdown()) - } - BlockNode::Blockquote { children, .. } => { - let content = children - .iter() - .map(|child| child.to_markdown()) - .collect::>() - .join("\n\n"); - - content - .lines() - .map(|line| format!("> {}", line)) - .collect::>() - .join("\n") - } - BlockNode::List { - children, ordered, .. - } => children - .iter() - .enumerate() - .map(|(i, child)| { - let prefix = if *ordered { - format!("{}. ", i + 1) - } else { - "- ".to_string() - }; - format!("{}{}", prefix, child.to_markdown()) - }) - .collect::>() - .join("\n"), - BlockNode::ListItem { - children, checked, .. - } => { - let checkbox = if let Some(checked) = checked { - if *checked { "[x] " } else { "[ ] " } - } else { - "" - }; - format!( - "{}{}", - checkbox, - children - .iter() - .map(|child| child.to_markdown()) - .collect::>() - .join("\n") - ) - } - BlockNode::CodeBlock(code_block) => { - format!( - "```{}\n{}\n```", - code_block.lang.clone().unwrap_or_default(), - code_block.code() - ) - } - BlockNode::Table(table) => { - let header = table - .children - .first() - .map(|row| { - row.children - .iter() - .map(|cell| cell.children.to_markdown()) - .collect::>() - .join(" | ") - }) - .unwrap_or_default(); - let alignments = table - .column_aligns - .iter() - .map(|align| { - match align { - ColumnumnAlign::Left => ":--", - ColumnumnAlign::Center => ":-:", - ColumnumnAlign::Right => "--:", - } - .to_string() - }) - .collect::>() - .join(" | "); - let rows = table - .children - .iter() - .skip(1) - .map(|row| { - row.children - .iter() - .map(|cell| cell.children.to_markdown()) - .collect::>() - .join(" | ") - }) - .collect::>() - .join("\n"); - format!("{}\n{}\n{}", header, alignments, rows) - } - BlockNode::Break { html, .. } => { - if *html { - "
".to_string() - } else { - "\n".to_string() - } - } - BlockNode::HorizontalRule { .. } => "---".to_string(), - BlockNode::Definition { - identifier, - url, - title, - .. - } => { - if let Some(title) = title { - format!("[{}]: {} \"{}\"", identifier, url, title) - } else { - format!("[{}]: {}", identifier, url) - } - } - BlockNode::Unknown { .. } => "".to_string(), - } - .trim() - .to_string() - } -} - -impl BlockNode { - fn render_list_item( - item: &BlockNode, - ix: usize, - options: NodeRenderOptions, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - match item { - BlockNode::ListItem { - children, - spread, - checked, - .. - } => v_flex() - .id(("li", options.ix)) - .w_full() - .min_w_0() - .when(*spread, |this| this.child(div())) - .children({ - let mut items: Vec
= Vec::with_capacity(children.len()); - - for (child_ix, child) in children.iter().enumerate() { - match child { - BlockNode::Paragraph { .. } => { - let last_not_list = child_ix > 0 - && !matches!(children[child_ix - 1], BlockNode::List { .. }); - - let text = child.render_block( - NodeRenderOptions { - depth: options.depth + 1, - todo: checked.is_some(), - is_last: true, - ..options - }, - node_cx, - window, - cx, - ); - - // Continuation paragraph — stack vertically below - // the previous row, indented to align with the text - // column (past bullet/number prefix). - if last_not_list { - if let Some(preceding_row) = items.pop() { - items.push( - v_flex().child(preceding_row).child( - div() - .w_full() - .pl(rems(0.75)) - .overflow_hidden() - .child(text), - ), - ); - continue; - } - } - - items.push( - h_flex() - .w_full() - .flex_1() - .min_w_0() - .relative() - .items_start() - .content_start() - .when(!options.todo && checked.is_none(), |this| { - this.child(list_item_prefix( - ix, - options.ordered, - options.depth, - )) - }) - .when_some(*checked, |this, checked| { - // Todo list checkbox - this.child( - div() - .flex() - .mt(rems(0.4)) - .mr_1p5() - .size(rems(0.875)) - .items_center() - .justify_center() - .rounded(cx.theme().radius.half()) - .border_1() - .border_color(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .when(checked, |this| { - this.bg(cx.theme().primary).child( - Icon::new(IconName::Check) - .size_2() - .text_xs(), - ) - }), - ) - }) - .child( - div().flex_1().min_w_0().overflow_hidden().child(text), - ), - ); - } - BlockNode::List { .. } => { - items.push(div().ml(rems(1.)).child(child.render_block( - NodeRenderOptions { - depth: options.depth + 1, - todo: checked.is_some(), - is_last: true, - ..options - }, - node_cx, - window, - cx, - ))); - } - _ => {} - } - } - items - }) - .into_any_element(), - _ => div().into_any_element(), - } - } - - fn render_table( - item: &BlockNode, - options: &NodeRenderOptions, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - const DEFAULT_LENGTH: usize = 5; - const MAX_LENGTH: usize = 150; - let col_lens = match item { - BlockNode::Table(table) => { - let mut col_lens = vec![]; - for row in table.children.iter() { - for (ix, cell) in row.children.iter().enumerate() { - if col_lens.len() <= ix { - col_lens.push(DEFAULT_LENGTH); - } - - let len = cell.children.text_len(); - if len > col_lens[ix] { - col_lens[ix] = len; - } - } - } - col_lens - } - _ => vec![], - }; - - match item { - BlockNode::Table(table) => div() - .pb(rems(1.)) - .w_full() - .child( - div() - .id(("table", options.ix)) - .w_full() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .overflow_hidden() - .children({ - let mut rows = Vec::with_capacity(table.children.len()); - for (row_ix, row) in table.children.iter().enumerate() { - rows.push( - div() - .id("row") - .w_full() - .when(row_ix < table.children.len() - 1, |this| { - this.border_b_1() - }) - .border_color(cx.theme().border) - .flex() - .flex_row() - .children({ - let mut cells = Vec::with_capacity(row.children.len()); - for (ix, cell) in row.children.iter().enumerate() { - let align = table.column_align(ix); - let is_last_col = ix == row.children.len() - 1; - let len = col_lens - .get(ix) - .copied() - .unwrap_or(MAX_LENGTH) - .min(MAX_LENGTH); - - cells.push( - div() - .id(("cell", ix)) - .overflow_hidden() - .when( - align == ColumnumnAlign::Center, - |this| this.text_center(), - ) - .when( - align == ColumnumnAlign::Right, - |this| this.text_right(), - ) - .min_w_16() - .w(Length::Definite(relative(len as f32))) - .px_2() - .py_1() - .when(!is_last_col, |this| { - this.border_r_1() - .border_color(cx.theme().border) - }) - .child( - cell.children - .render(node_cx, window, cx), - ), - ) - } - cells - }), - ) - } - rows - }), - ) - .into_any_element(), - _ => div().into_any_element(), - } - } - - pub(crate) fn render_block( - &self, - options: NodeRenderOptions, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - let ix = options.ix; - let mb = if options.in_list || options.is_last { - rems(0.) - } else { - node_cx.style.paragraph_gap - }; - - match self { - BlockNode::Root { children, .. } => div() - .id(("div", ix)) - .children(children.into_iter().enumerate().map(move |(ix, node)| { - node.render_block(NodeRenderOptions { ix, ..options }, node_cx, window, cx) - })) - .into_any_element(), - BlockNode::Paragraph(paragraph) => div() - .id(("p", ix)) - .pb(mb) - .child(paragraph.render(node_cx, window, cx)) - .into_any_element(), - BlockNode::Heading { - level, children, .. - } => { - let (text_size, font_weight) = match level { - 1 => (rems(2.), FontWeight::BOLD), - 2 => (rems(1.5), FontWeight::SEMIBOLD), - 3 => (rems(1.25), FontWeight::SEMIBOLD), - 4 => (rems(1.125), FontWeight::SEMIBOLD), - 5 => (rems(1.), FontWeight::SEMIBOLD), - 6 => (rems(1.), FontWeight::MEDIUM), - _ => (rems(1.), FontWeight::NORMAL), - }; - - let mut text_size = text_size.to_pixels(node_cx.style.heading_base_font_size); - if let Some(f) = node_cx.style.heading_font_size.as_ref() { - text_size = (f)(*level, node_cx.style.heading_base_font_size); - } - - div() - .id(SharedString::from(format!("h{}-{}", level, ix))) - .pb(rems(0.3)) - .whitespace_normal() - .text_size(text_size) - .font_weight(font_weight) - .child(children.render(node_cx, window, cx)) - .into_any_element() - } - BlockNode::Blockquote { children, .. } => div() - .w_full() - .pb(mb) - .child( - div() - .id(("blockquote", ix)) - .w_full() - .text_color(cx.theme().muted_foreground) - .border_l_3() - .border_color(cx.theme().secondary_active) - .px_4() - .children({ - let children_len = children.len(); - children.into_iter().enumerate().map(move |(index, c)| { - let is_last = index == children_len - 1; - c.render_block(options.is_last(is_last), node_cx, window, cx) - }) - }), - ) - .into_any_element(), - BlockNode::List { - children, ordered, .. - } => v_flex() - .id((if *ordered { "ol" } else { "ul" }, ix)) - .pb(mb) - .children({ - let mut items = Vec::with_capacity(children.len()); - let mut item_index = 0; - for (ix, item) in children.into_iter().enumerate() { - let is_item = item.is_list_item(); - - items.push(Self::render_list_item( - item, - item_index, - NodeRenderOptions { - ix, - ordered: *ordered, - ..options - }, - node_cx, - window, - cx, - )); - - if is_item { - item_index += 1; - } - } - items - }) - .into_any_element(), - BlockNode::CodeBlock(code_block) => code_block.render(&options, node_cx, window, cx), - BlockNode::Table { .. } => { - Self::render_table(self, &options, node_cx, window, cx).into_any_element() - } - BlockNode::HorizontalRule { .. } => div() - .pb(mb) - .child(div().id("horizontal-rule").bg(cx.theme().border).h(px(2.))) - .into_any_element(), - BlockNode::Break { .. } => div().id("break").into_any_element(), - BlockNode::Unknown { .. } | BlockNode::Definition { .. } => div().into_any_element(), - _ => { - if cfg!(debug_assertions) { - tracing::warn!("unknown implementation: {:?}", self); - } - - div().into_any_element() - } - } - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/state.rs b/vendor/gpui-component/crates/ui/src/text/state.rs deleted file mode 100644 index e0898b47..00000000 --- a/vendor/gpui-component/crates/ui/src/text/state.rs +++ /dev/null @@ -1,562 +0,0 @@ -use futures::Stream as _; -use std::{pin::Pin, task::Poll}; - -use gpui::{ - App, AppContext as _, Bounds, ClipboardItem, Context, FocusHandle, IntoElement, KeyBinding, - ListState, ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window, - prelude::FluentBuilder as _, px, -}; - -use crate::{ - ActiveTheme, ElementExt, - async_util::{Receiver, Sender, unbounded}, - highlighter::HighlightTheme, - input::{self, Copy}, - text::{ - CodeBlockActionsFn, TextViewStyle, - document::ParsedDocument, - format, - node::{self, NodeContext}, - }, - v_flex, -}; - -const CONTEXT: &'static str = "TextView"; -pub(crate) fn init(cx: &mut App) { - cx.bind_keys(vec![ - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)), - ]); -} - -/// The content format of the text view. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum TextViewFormat { - /// Markdown view - Markdown, - /// HTML view - Html, -} - -/// The state of a TextView. -pub struct TextViewState { - pub(super) focus_handle: FocusHandle, - pub(super) list_state: ListState, - - /// The bounds of the text view - bounds: Bounds, - - pub(super) selectable: bool, - pub(super) scrollable: bool, - pub(super) text_view_style: TextViewStyle, - pub(super) code_block_actions: Option>, - - pub(super) is_selecting: bool, - /// The local (in TextView) position of the selection. - selection_positions: (Option>, Option>), - - pub(super) parsed_content: ParsedContent, - text: String, - parsed_error: Option, - tx: Sender, - _parse_task: Task<()>, - _receive_task: Task<()>, -} - -impl TextViewState { - /// Create a Markdown TextViewState. - pub fn markdown(text: &str, cx: &mut Context) -> Self { - Self::new(TextViewFormat::Markdown, text, cx) - } - - /// Create a HTML TextViewState. - pub fn html(text: &str, cx: &mut Context) -> Self { - Self::new(TextViewFormat::Html, text, cx) - } - - /// Create a new TextViewState. - fn new(format: TextViewFormat, text: &str, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - - let (tx, rx) = unbounded::(); - let (tx_result, rx_result) = unbounded::>(); - let _receive_task = cx.spawn({ - async move |weak_self, cx| { - while let Ok(parsed_result) = rx_result.recv().await { - _ = weak_self.update(cx, |state, cx| { - match parsed_result { - Ok(content) => { - state.parsed_content = content; - state.parsed_error = None; - } - Err(err) => { - state.parsed_error = Some(err); - } - } - state.clear_selection(); - cx.notify(); - }); - } - } - }); - - let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result, cx)); - - let mut this = Self { - focus_handle, - bounds: Bounds::default(), - selection_positions: (None, None), - selectable: false, - scrollable: false, - list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), - text_view_style: TextViewStyle::default(), - code_block_actions: None, - is_selecting: false, - parsed_content: Default::default(), - parsed_error: None, - text: text.to_string(), - tx, - _parse_task, - _receive_task, - }; - this.increment_update(&text, false, cx); - this - } - - /// Get the text content. - pub(crate) fn source(&self) -> SharedString { - self.parsed_content.document.source.clone() - } - - /// Set whether the text is selectable, default false. - pub fn selectable(mut self, selectable: bool) -> Self { - self.selectable = selectable; - self - } - - /// Set whether the text is selectable, default false. - pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context) { - self.selectable = selectable; - cx.notify(); - } - - /// Set whether the text is selectable, default false. - pub fn scrollable(mut self, scrollable: bool) -> Self { - self.scrollable = scrollable; - self - } - - /// Set whether the text is selectable, default false. - pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context) { - self.scrollable = scrollable; - cx.notify(); - } - - /// Set the text content. - pub fn set_text(&mut self, text: &str, cx: &mut Context) { - if self.text.as_str() == text { - return; - } - - self.text.clear(); - self.text.push_str(text); - self.parsed_error = None; - self.increment_update(text, false, cx); - } - - /// Append partial text content to the existing text. - pub fn push_str(&mut self, new_text: &str, cx: &mut Context) { - if new_text.is_empty() { - return; - } - self.text.push_str(new_text); - self.increment_update(new_text, true, cx); - } - - /// Return the selected text. - pub fn selected_text(&self) -> String { - self.parsed_content.document.selected_text() - } - - fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context) { - let update_options = UpdateOptions { - append, - pending_text: text.to_string(), - highlight_theme: cx.theme().highlight_theme.clone(), - }; - - _ = self.tx.try_send(update_options); - } - - /// Save bounds and unselect if bounds changed. - pub(super) fn update_bounds(&mut self, bounds: Bounds) { - if self.bounds.size != bounds.size { - self.clear_selection(); - } - self.bounds = bounds; - } - - pub(super) fn clear_selection(&mut self) { - self.selection_positions = (None, None); - self.is_selecting = false; - } - - pub(super) fn start_selection(&mut self, pos: Point) { - // Store content coordinates (not affected by scrolling) - let scroll_offset = if self.scrollable { - self.list_state.scroll_px_offset_for_scrollbar() - } else { - Point::default() - }; - let pos = pos - self.bounds.origin - scroll_offset; - self.selection_positions = (Some(pos), Some(pos)); - self.is_selecting = true; - } - - pub(super) fn update_selection(&mut self, pos: Point) { - let scroll_offset = if self.scrollable { - self.list_state.scroll_px_offset_for_scrollbar() - } else { - Point::default() - }; - let pos = pos - self.bounds.origin - scroll_offset; - if let (Some(start), Some(_)) = self.selection_positions { - self.selection_positions = (Some(start), Some(pos)) - } - } - - pub(super) fn end_selection(&mut self) { - self.is_selecting = false; - } - - pub(crate) fn has_selection(&self) -> bool { - if let (Some(start), Some(end)) = self.selection_positions { - start != end - } else { - false - } - } - - /// Return the selection start/end in window coordinates. - pub(crate) fn selection_points(&self) -> Option<(Point, Point)> { - let scroll_offset = if self.scrollable { - self.list_state.scroll_px_offset_for_scrollbar() - } else { - Point::default() - }; - - selection_points( - self.selection_positions.0, - self.selection_positions.1, - self.bounds, - scroll_offset, - ) - } - - pub(super) fn on_action_copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - let selected_text = self.selected_text().trim().to_string(); - if selected_text.is_empty() { - return; - } - - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - } - - pub(crate) fn is_selectable(&self) -> bool { - self.selectable - } -} - -impl Render for TextViewState { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let state = cx.entity(); - let document = self.parsed_content.document.clone(); - let mut node_cx = self.parsed_content.node_cx.clone(); - - node_cx.code_block_actions = self.code_block_actions.clone(); - node_cx.style = self.text_view_style.clone(); - - v_flex() - .size_full() - .map(|this| match &mut self.parsed_error { - None => this.child(document.render_root( - if self.scrollable { - Some(self.list_state.clone()) - } else { - None - }, - &node_cx, - window, - cx, - )), - Some(err) => this.child( - v_flex() - .gap_1() - .child("Failed to parse content") - .child(err.to_string()), - ), - }) - .on_prepaint(move |bounds, _, cx| { - state.update(cx, |state, _| { - state.update_bounds(bounds); - }) - }) - } -} - -#[derive(Clone, PartialEq, Default)] -pub(crate) struct ParsedContent { - pub(crate) document: ParsedDocument, - pub(crate) node_cx: node::NodeContext, -} - -struct UpdateFuture { - format: TextViewFormat, - content: ParsedContent, - options: UpdateOptions, - pending_text: String, - rx: Pin>>, - tx_result: Sender>, -} - -impl UpdateFuture { - fn new( - format: TextViewFormat, - rx: Receiver, - tx_result: Sender>, - cx: &App, - ) -> Self { - Self { - format, - content: Default::default(), - pending_text: String::new(), - options: UpdateOptions { - append: false, - pending_text: String::new(), - highlight_theme: cx.theme().highlight_theme.clone(), - }, - rx: Box::pin(rx), - tx_result, - } - } -} - -impl Future for UpdateFuture { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { - loop { - match self.rx.as_mut().poll_next(cx) { - Poll::Ready(Some(options)) => { - if options.append { - self.pending_text.push_str(options.pending_text.as_str()); - } else { - self.pending_text = options.pending_text.clone(); - } - self.options = options; - - // Process immediately without debounce - let pending_text = std::mem::take(&mut self.pending_text); - let options = UpdateOptions { - pending_text, - ..self.options.clone() - }; - let res = parse_content(self.format, self.content.clone(), &options); - if let Ok(content) = &res { - self.content = content.clone(); - } - _ = self.tx_result.try_send(res); - continue; - } - Poll::Ready(None) => return Poll::Ready(()), - Poll::Pending => return Poll::Pending, - } - } - } -} - -#[derive(Clone)] -struct UpdateOptions { - pending_text: String, - append: bool, - highlight_theme: std::sync::Arc, -} - -fn parse_content( - format: TextViewFormat, - mut content: ParsedContent, - options: &UpdateOptions, -) -> Result { - let mut node_cx = NodeContext { - ..NodeContext::default() - }; - - let mut source = String::new(); - if options.append - && let Some(last_block) = content.document.blocks.pop() - && let Some(span) = last_block.span() - { - node_cx.offset = span.start; - let last_source = &content.document.source[span.start..]; - source.push_str(last_source); - source.push_str(&options.pending_text); - } else { - source = options.pending_text.to_string(); - } - - let new_document = match format { - TextViewFormat::Markdown => { - format::markdown::parse(&source, &mut node_cx, &options.highlight_theme) - } - TextViewFormat::Html => format::html::parse(&source, &mut node_cx), - }?; - - if options.append { - content.document.source = - format!("{}{}", content.document.source, options.pending_text).into(); - content.document.blocks.extend(new_document.blocks); - } else { - content.document = new_document; - } - - Ok(content) -} - -fn selection_points( - start: Option>, - end: Option>, - bounds: Bounds, - scroll_offset: Point, -) -> Option<(Point, Point)> { - if let (Some(start), Some(end)) = (start, end) { - // Convert content coordinates to window coordinates - let start = start + scroll_offset + bounds.origin; - let end = end + scroll_offset + bounds.origin; - return Some((start, end)); - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::{TestAppContext, point}; - - #[gpui::test] - fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) { - cx.update(crate::init); - let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx))); - cx.run_until_parked(); - - state.update(cx, |state, cx| { - state.set_text("", cx); - state.push_str("new", cx); - state.push_str(" text", cx); - }); - cx.run_until_parked(); - - state.read_with(cx, |state, _| { - assert_eq!(state.text.as_str(), "new text"); - assert_eq!(state.source().as_str(), "new text"); - }); - - state.update(cx, |state, cx| { - state.set_text("", cx); - }); - cx.run_until_parked(); - - state.read_with(cx, |state, _| { - assert_eq!(state.text.as_str(), ""); - assert_eq!(state.source().as_str(), ""); - }); - } - - #[test] - fn test_text_view_state_selection_points() { - assert_eq!( - selection_points(None, None, Default::default(), Point::default()), - None - ); - assert_eq!( - selection_points( - None, - Some(point(px(10.), px(20.))), - Default::default(), - Point::default() - ), - None - ); - assert_eq!( - selection_points( - Some(point(px(10.), px(20.))), - None, - Default::default(), - Point::default() - ), - None - ); - - // 10,10 start - // |------| - // | | - // |------| - // 50,50 - assert_eq!( - selection_points( - Some(point(px(10.), px(10.))), - Some(point(px(50.), px(50.))), - Default::default(), - Point::default() - ), - Some((point(px(10.), px(10.)), point(px(50.), px(50.)))) - ); - - // 10,10 - // |------| - // | | - // |------| - // 50,50 start - assert_eq!( - selection_points( - Some(point(px(50.), px(50.))), - Some(point(px(10.), px(10.))), - Default::default(), - Point::default() - ), - Some((point(px(50.), px(50.)), point(px(10.), px(10.)))) - ); - - // 50,10 start - // |------| - // | | - // |------| - // 10,50 - assert_eq!( - selection_points( - Some(point(px(50.), px(10.))), - Some(point(px(10.), px(50.))), - Default::default(), - Point::default() - ), - Some((point(px(50.), px(10.)), point(px(10.), px(50.)))) - ); - - // 50,10 - // |------| - // | | - // |------| - // 10,50 start - assert_eq!( - selection_points( - Some(point(px(10.), px(50.))), - Some(point(px(50.), px(10.))), - Default::default(), - Point::default() - ), - Some((point(px(10.), px(50.)), point(px(50.), px(10.)))) - ); - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/style.rs b/vendor/gpui-component/crates/ui/src/text/style.rs deleted file mode 100644 index 167b7259..00000000 --- a/vendor/gpui-component/crates/ui/src/text/style.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::sync::Arc; - -use gpui::{Pixels, Rems, StyleRefinement, px, rems}; - -use crate::highlighter::HighlightTheme; - -/// TextViewStyle used to customize the style for [`TextView`]. -#[derive(Clone)] -pub struct TextViewStyle { - /// Gap of each paragraphs, default is 1 rem. - pub paragraph_gap: Rems, - /// Base font size for headings, default is 14px. - pub heading_base_font_size: Pixels, - /// Function to calculate heading font size based on heading level (1-6). - /// - /// The first parameter is the heading level (1-6), the second parameter is the base font size. - /// The second parameter is the base font size. - pub heading_font_size: Option Pixels + Send + Sync + 'static>>, - /// Highlight theme for code blocks. Default: [`HighlightTheme::default_light()`] - pub highlight_theme: Arc, - /// The style refinement for code blocks. - pub code_block: StyleRefinement, - pub is_dark: bool, -} - -impl PartialEq for TextViewStyle { - fn eq(&self, other: &Self) -> bool { - self.paragraph_gap == other.paragraph_gap - && self.heading_base_font_size == other.heading_base_font_size - && self.highlight_theme == other.highlight_theme - } -} - -impl Default for TextViewStyle { - fn default() -> Self { - Self { - paragraph_gap: rems(1.), - heading_base_font_size: px(14.), - heading_font_size: None, - highlight_theme: HighlightTheme::default_light().clone(), - code_block: StyleRefinement::default(), - is_dark: false, - } - } -} - -impl TextViewStyle { - /// Set paragraph gap, default is 1 rem. - pub fn paragraph_gap(mut self, gap: Rems) -> Self { - self.paragraph_gap = gap; - self - } - - pub fn heading_font_size(mut self, f: F) -> Self - where - F: Fn(u8, Pixels) -> Pixels + Send + Sync + 'static, - { - self.heading_font_size = Some(Arc::new(f)); - self - } - - /// Set style for code blocks. - pub fn code_block(mut self, style: StyleRefinement) -> Self { - self.code_block = style; - self - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/text_view.rs b/vendor/gpui-component/crates/ui/src/text/text_view.rs deleted file mode 100644 index 8409f1f3..00000000 --- a/vendor/gpui-component/crates/ui/src/text/text_view.rs +++ /dev/null @@ -1,409 +0,0 @@ -use std::sync::Arc; - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - AnyElement, App, Bounds, Element, ElementId, Entity, GlobalElementId, Hitbox, HitboxBehavior, - InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, ParentElement, Pixels, SharedString, StyleRefinement, Styled, Window, div, -}; - -use crate::StyledExt; -use crate::scroll::ScrollableElement; -use crate::text::TextViewFormat; -use crate::text::node::CodeBlock; -use crate::text::state::TextViewState; -use crate::{global_state::GlobalState, text::TextViewStyle}; - -/// Type for code block actions generator function. -pub(crate) type CodeBlockActionsFn = - dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync; - -/// A text view that can render Markdown or HTML. -/// -/// ## Goals -/// -/// - Provide a rich text rendering component for such as Markdown or HTML, -/// used to display rich text in GPUI application (e.g., Help messages, Release notes) -/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups. -/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ... -/// -/// ## Not Goals -/// -/// - Customization of the complex style (some simple styles will be supported) -/// - As a Markdown editor or viewer (If you want to like this, you must fork your version). -/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader. -/// -/// See also [`MarkdownElement`], [`HtmlElement`] -#[derive(Clone)] -pub struct TextView { - id: ElementId, - format: Option, - text: Option, - pub(crate) state: Option>, - text_view_style: TextViewStyle, - style: StyleRefinement, - selectable: bool, - scrollable: bool, - code_block_actions: Option>, -} - -impl Styled for TextView { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl TextView { - /// Create new TextView with managed state. - pub fn new(state: &Entity) -> Self { - Self { - id: ElementId::Name(state.entity_id().to_string().into()), - state: Some(state.clone()), - format: None, - text: None, - text_view_style: TextViewStyle::default(), - style: StyleRefinement::default(), - selectable: false, - scrollable: false, - code_block_actions: None, - } - } - - /// Create a new markdown text view. - pub fn markdown(id: impl Into, markdown: impl Into) -> Self { - Self { - id: id.into(), - format: Some(TextViewFormat::Markdown), - text: Some(markdown.into()), - text_view_style: TextViewStyle::default(), - style: StyleRefinement::default(), - state: None, - selectable: false, - scrollable: false, - code_block_actions: None, - } - } - - /// Create a new html text view. - pub fn html(id: impl Into, html: impl Into) -> Self { - Self { - id: id.into(), - format: Some(TextViewFormat::Html), - text: Some(html.into()), - text_view_style: TextViewStyle::default(), - style: StyleRefinement::default(), - state: None, - selectable: false, - scrollable: false, - code_block_actions: None, - } - } - - /// Set [`TextViewStyle`]. - pub fn style(mut self, style: TextViewStyle) -> Self { - self.text_view_style = style; - self - } - - /// Set the text view to be selectable, default is false. - pub fn selectable(mut self, selectable: bool) -> Self { - self.selectable = selectable; - self - } - - /// Set the text view to be scrollable, default is false. - /// - /// ## If true for `scrollable` - /// - /// The `scrollable` mode used for large content, - /// will show scrollbar, but requires the parent to have a fixed height, - /// and use [`gpui::list`] to render the content in a virtualized way. - /// - /// ## If false to fit content - /// - /// The TextView will expand to fit all content, no scrollbar. - /// This mode is suitable for small content, such as a few lines of text, a label, etc. - pub fn scrollable(mut self, scrollable: bool) -> Self { - self.scrollable = scrollable; - self - } - - /// Set custom block actions for code blocks. - /// - /// The closure receives the [`CodeBlock`], - /// and returns an element to display. - pub fn code_block_actions(mut self, f: F) -> Self - where - F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static, - E: IntoElement, - { - self.code_block_actions = Some(Arc::new(move |code_block, window, cx| { - f(&code_block, window, cx).into_any_element() - })); - self - } -} - -impl IntoElement for TextView { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -pub struct TextViewLayoutState { - state: Entity, - element: AnyElement, -} - -impl Element for TextView { - type RequestLayoutState = TextViewLayoutState; - type PrepaintState = Hitbox; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let state = if let Some(state) = self.state.clone() { - state - } else { - let default_format = self.format.unwrap_or(TextViewFormat::Markdown); - let default_text = self.text.clone().unwrap_or_default(); - - let state = window.use_keyed_state( - SharedString::from(format!("{}/state", self.id)), - cx, - move |_, cx| { - if default_format == TextViewFormat::Markdown { - TextViewState::markdown(default_text.as_str(), cx) - } else { - TextViewState::html(default_text.as_str(), cx) - } - }, - ); - self.state = Some(state.clone()); - state - }; - - state.update(cx, |state, cx| { - state.code_block_actions = self.code_block_actions.clone(); - state.selectable = self.selectable; - state.scrollable = self.scrollable; - state.text_view_style = self.text_view_style.clone(); - - if let Some(text) = self.text.clone() { - state.set_text(text.as_str(), cx); - } - }); - - let focus_handle = state.read(cx).focus_handle.clone(); - let list_state = state.read(cx).list_state.clone(); - - let mut el = div() - .key_context("TextView") - .track_focus(&focus_handle) - .when(self.scrollable, |this| { - this.size_full().vertical_scrollbar(&list_state) - }) - .relative() - .on_action(window.listener_for(&state, TextViewState::on_action_copy)) - .child(state.clone()) - .refine_style(&self.style) - .into_any_element(); - let layout_id = el.request_layout(window, cx); - (layout_id, TextViewLayoutState { state, element: el }) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - request_layout.element.prepaint(window, cx); - window.insert_hitbox(bounds, HitboxBehavior::Normal) - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - hitbox: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let state = &request_layout.state; - GlobalState::global_mut(cx) - .text_view_state_stack - .push(state.clone()); - request_layout.element.paint(window, cx); - GlobalState::global_mut(cx).text_view_state_stack.pop(); - - if self.selectable { - let is_selecting = state.read(cx).is_selecting; - let has_selection = state.read(cx).has_selection(); - let parent_view_id = window.current_view(); - - window.on_mouse_event({ - let state = state.clone(); - let hitbox = hitbox.clone(); - move |event: &MouseDownEvent, phase, window, cx| { - if !phase.bubble() || !hitbox.is_hovered(window) { - return; - } - - state.update(cx, |state, _| { - state.start_selection(event.position); - }); - cx.notify(parent_view_id); - } - }); - - if is_selecting { - // move to update end position. - window.on_mouse_event({ - let state = state.clone(); - move |event: &MouseMoveEvent, phase, _, cx| { - if !phase.bubble() { - return; - } - - state.update(cx, |state, _| { - state.update_selection(event.position); - }); - cx.notify(parent_view_id); - } - }); - - // up to end selection - window.on_mouse_event({ - let state = state.clone(); - move |_: &MouseUpEvent, phase, _, cx| { - if !phase.bubble() { - return; - } - - state.update(cx, |state, _| { - state.end_selection(); - }); - cx.notify(parent_view_id); - } - }); - } - - if has_selection { - // down outside to clear selection - window.on_mouse_event({ - let state = state.clone(); - let hitbox = hitbox.clone(); - move |_: &MouseDownEvent, _, window, cx| { - if hitbox.is_hovered(window) { - return; - } - - state.update(cx, |state, _| { - state.clear_selection(); - }); - cx.notify(parent_view_id); - } - }); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::TextView; - use crate::text::TextViewState; - use gpui::{ - AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement as _, - Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, - }; - - struct TextViewTestRoot { - text_view: Entity, - } - - impl TextViewTestRoot { - fn new(text: &str, cx: &mut Context) -> Self { - let text = text.to_string(); - let text_view = cx.new(|cx| TextViewState::markdown(&text, cx)); - Self { text_view } - } - } - - impl Render for TextViewTestRoot { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .w(px(160.)) - .child( - div() - .h(px(24.)) - .overflow_hidden() - .child(TextView::new(&self.text_view).selectable(true)), - ) - .child(div().h(px(40.)).child("footer")) - } - } - - #[gpui::test] - fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) { - cx.update(crate::init); - let (_, cx) = cx.add_window_view(|_, cx| { - TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx) - }); - let cx: &mut VisualTestContext = cx; - - cx.simulate_click(point(px(10.), px(34.)), Modifiers::default()); - - assert_eq!(cx.opened_url(), None); - } - - #[gpui::test] - fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) { - cx.update(crate::init); - let (view, cx) = cx - .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx)); - let cx: &mut VisualTestContext = cx; - - cx.simulate_mouse_down( - point(px(10.), px(34.)), - MouseButton::Left, - Modifiers::default(), - ); - cx.simulate_mouse_move( - point(px(90.), px(34.)), - Some(MouseButton::Left), - Modifiers::default(), - ); - cx.simulate_mouse_up( - point(px(90.), px(34.)), - MouseButton::Left, - Modifiers::default(), - ); - - let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text()); - assert!( - selected_text.is_empty(), - "unexpected selection: {selected_text:?}" - ); - } -} diff --git a/vendor/gpui-component/crates/ui/src/text/utils.rs b/vendor/gpui-component/crates/ui/src/text/utils.rs deleted file mode 100644 index 546bd68f..00000000 --- a/vendor/gpui-component/crates/ui/src/text/utils.rs +++ /dev/null @@ -1,61 +0,0 @@ -const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; - -const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; - -/// Returns the prefix for a list item. -pub(super) fn list_item_prefix(ix: usize, ordered: bool, depth: usize) -> String { - if ordered { - if depth == 0 { - return format!("{}. ", ix + 1); - } - - if depth == 1 { - return format!( - "{}. ", - NUMBERED_PREFIXES_1 - .chars() - .nth(ix % NUMBERED_PREFIXES_1.len()) - .unwrap() - ); - } else { - return format!( - "{}. ", - NUMBERED_PREFIXES_2 - .chars() - .nth(ix % NUMBERED_PREFIXES_2.len()) - .unwrap() - ); - } - } else { - let depth = depth.min(BULLETS.len() - 1); - let bullet = BULLETS[depth]; - return format!("{} ", bullet); - } -} - -#[cfg(test)] -mod tests { - use crate::text::utils::list_item_prefix; - - #[test] - fn test_list_item_prefix() { - assert_eq!(list_item_prefix(0, true, 0), "1. "); - assert_eq!(list_item_prefix(1, true, 0), "2. "); - assert_eq!(list_item_prefix(2, true, 0), "3. "); - assert_eq!(list_item_prefix(10, true, 0), "11. "); - assert_eq!(list_item_prefix(0, true, 1), "A. "); - assert_eq!(list_item_prefix(1, true, 1), "B. "); - assert_eq!(list_item_prefix(2, true, 1), "C. "); - assert_eq!(list_item_prefix(0, true, 2), "a. "); - assert_eq!(list_item_prefix(1, true, 2), "b. "); - assert_eq!(list_item_prefix(6, true, 2), "g. "); - assert_eq!(list_item_prefix(0, true, 1), "A. "); - assert_eq!(list_item_prefix(0, true, 2), "a. "); - assert_eq!(list_item_prefix(0, false, 0), "• "); - assert_eq!(list_item_prefix(0, false, 1), "◦ "); - assert_eq!(list_item_prefix(0, false, 2), "▪ "); - assert_eq!(list_item_prefix(0, false, 3), "‣ "); - assert_eq!(list_item_prefix(0, false, 4), "⁃ "); - } -} diff --git a/vendor/gpui-component/crates/ui/src/theme/color.rs b/vendor/gpui-component/crates/ui/src/theme/color.rs deleted file mode 100644 index c7e32eda..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/color.rs +++ /dev/null @@ -1,918 +0,0 @@ -use std::{collections::HashMap, fmt::Display}; - -use gpui::{Hsla, SharedString, hsla}; -use serde::{Deserialize, Deserializer, de::Error as _}; - -use anyhow::{Error, Result, anyhow}; - -/// Create a [`gpui::Hsla`] color. -/// -/// - h: 0..360.0 -/// - s: 0.0..100.0 -/// - l: 0.0..100.0 -#[inline] -pub fn hsl(h: f32, s: f32, l: f32) -> Hsla { - hsla(h / 360., s / 100.0, l / 100.0, 1.0) -} - -pub trait Colorize: Sized { - /// Returns a new color with the given opacity. - /// - /// The opacity is a value between 0.0 and 1.0, where 0.0 is fully transparent and 1.0 is fully opaque. - fn opacity(&self, opacity: f32) -> Self; - /// Returns a new color with each channel divided by the given divisor. - /// - /// The divisor in range of 0.0 .. 1.0 - fn divide(&self, divisor: f32) -> Self; - /// Return inverted color - fn invert(&self) -> Self; - /// Return inverted lightness - fn invert_l(&self) -> Self; - /// Return a new color with the lightness increased by the given factor. - /// - /// factor range: 0.0 .. 1.0 - fn lighten(&self, amount: f32) -> Self; - /// Return a new color with the darkness increased by the given factor. - /// - /// factor range: 0.0 .. 1.0 - fn darken(&self, amount: f32) -> Self; - /// Return a new color with the same lightness and alpha but different hue and saturation. - fn apply(&self, base_color: Self) -> Self; - - /// Mix two colors together, the `factor` is a value between 0.0 and 1.0 for first color. - fn mix(&self, other: Self, factor: f32) -> Self; - /// Mix two colors together in Oklab color space, the `factor` is a value between 0.0 and 1.0 for first color. - /// - /// This is similar to CSS `color-mix(in oklab, color1 factor%, color2)`. - fn mix_oklab(&self, other: Self, factor: f32) -> Self; - /// Change the `Hue` of the color by the given in range: 0.0 .. 1.0 - fn hue(&self, hue: f32) -> Self; - /// Change the `Saturation` of the color by the given value in range: 0.0 .. 1.0 - fn saturation(&self, saturation: f32) -> Self; - /// Change the `Lightness` of the color by the given value in range: 0.0 .. 1.0 - fn lightness(&self, lightness: f32) -> Self; - - /// Convert the color to a hex string. For example, "#F8FAFC". - fn to_hex(&self) -> String; - /// Parse a hex string to a color. - fn parse_hex(hex: &str) -> Result; -} - -/// Helper functions for Oklab color space conversions -mod oklab { - use gpui::Rgba; - - /// Convert sRGB component to linear RGB - #[inline] - fn to_linear(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - - /// Convert linear RGB component to sRGB - #[inline] - fn from_linear(c: f32) -> f32 { - if c <= 0.0031308 { - c * 12.92 - } else { - 1.055 * c.powf(1.0 / 2.4) - 0.055 - } - } - - /// Convert RGB to Oklab color space - #[allow(non_snake_case)] - pub fn rgb_to_oklab(rgb: Rgba) -> (f32, f32, f32) { - // sRGB to linear RGB - let lr = to_linear(rgb.r); - let lg = to_linear(rgb.g); - let lb = to_linear(rgb.b); - - // Linear RGB to LMS - let l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; - let m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; - let s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; - - // LMS to Oklab (using cube root) - let l_ = l.cbrt(); - let m_ = m.cbrt(); - let s_ = s.cbrt(); - - let L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; - let a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; - let b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; - - (L, a, b) - } - - /// Convert Oklab to RGB color space - #[allow(non_snake_case)] - pub fn oklab_to_rgb(L: f32, a: f32, b: f32) -> Rgba { - // Oklab to LMS - let l_ = L + 0.3963377774 * a + 0.2158037573 * b; - let m_ = L - 0.1055613458 * a - 0.0638541728 * b; - let s_ = L - 0.0894841775 * a - 1.2914855480 * b; - - let l = l_ * l_ * l_; - let m = m_ * m_ * m_; - let s = s_ * s_ * s_; - - // LMS to Linear RGB - let lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; - let lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; - let lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; - - // Linear RGB to sRGB - Rgba { - r: from_linear(lr).clamp(0.0, 1.0), - g: from_linear(lg).clamp(0.0, 1.0), - b: from_linear(lb).clamp(0.0, 1.0), - a: 1.0, - } - } -} - -impl Colorize for Hsla { - fn opacity(&self, factor: f32) -> Self { - Self { - a: self.a * factor.clamp(0.0, 1.0), - ..*self - } - } - - fn divide(&self, divisor: f32) -> Self { - Self { - a: divisor, - ..*self - } - } - - fn invert(&self) -> Self { - Self { - h: 1.0 - self.h, - s: 1.0 - self.s, - l: 1.0 - self.l, - a: self.a, - } - } - - fn invert_l(&self) -> Self { - Self { - l: 1.0 - self.l, - ..*self - } - } - - fn lighten(&self, factor: f32) -> Self { - let l = self.l * (1.0 + factor.clamp(0.0, 1.0)); - - Hsla { l, ..*self } - } - - fn darken(&self, factor: f32) -> Self { - let l = self.l * (1.0 - factor.clamp(0.0, 1.0)); - - Self { l, ..*self } - } - - fn apply(&self, new_color: Self) -> Self { - Hsla { - h: new_color.h, - s: new_color.s, - l: self.l, - a: self.a, - } - } - - /// Reference: - /// https://github.com/bevyengine/bevy/blob/85eceb022da0326b47ac2b0d9202c9c9f01835bb/crates/bevy_color/src/hsla.rs#L112 - fn mix(&self, other: Self, factor: f32) -> Self { - let factor = factor.clamp(0.0, 1.0); - let inv = 1.0 - factor; - - #[inline] - fn lerp_hue(a: f32, b: f32, t: f32) -> f32 { - let diff = (b - a + 180.0).rem_euclid(360.) - 180.; - (a + diff * t).rem_euclid(360.0) - } - - Hsla { - h: lerp_hue(self.h * 360., other.h * 360., factor) / 360., - s: self.s * factor + other.s * inv, - l: self.l * factor + other.l * inv, - a: self.a * factor + other.a * inv, - } - } - - #[allow(non_snake_case)] - fn mix_oklab(&self, other: Self, factor: f32) -> Self { - let factor = factor.clamp(0.0, 1.0); - let inv = 1.0 - factor; - - // Interpolate alpha first - let result_alpha = self.a * factor + other.a * inv; - - // Handle the case where result alpha is zero - if result_alpha == 0.0 { - return Self { - h: 0.0, - s: 0.0, - l: 0.0, - a: 0.0, - }; - } - - // Convert both colors to RGB - let rgb1 = self.to_rgb(); - let rgb2 = other.to_rgb(); - - // Convert to Oklab color space - let (l1, a1, b1) = oklab::rgb_to_oklab(rgb1); - let (l2, a2, b2) = oklab::rgb_to_oklab(rgb2); - - // Premultiply alpha in Oklab space (using alpha-premultiplied interpolation) - // This matches CSS color-mix behavior - let alpha1 = self.a; - let alpha2 = other.a; - - // Premultiply - let l1_pm = l1 * alpha1; - let a1_pm = a1 * alpha1; - let b1_pm = b1 * alpha1; - - let l2_pm = l2 * alpha2; - let a2_pm = a2 * alpha2; - let b2_pm = b2 * alpha2; - - // Interpolate premultiplied values - let L_pm = l1_pm * factor + l2_pm * inv; - let a_pm = a1_pm * factor + a2_pm * inv; - let b_pm = b1_pm * factor + b2_pm * inv; - - // Unpremultiply - let L = L_pm / result_alpha; - let a = a_pm / result_alpha; - let b = b_pm / result_alpha; - - // Convert back to RGB - let mut rgb = oklab::oklab_to_rgb(L, a, b); - rgb.a = result_alpha; - - // Convert RGB to HSLA - rgb.into() - } - - fn to_hex(&self) -> String { - let rgb = self.to_rgb(); - - if rgb.a < 1. { - return format!( - "#{:02X}{:02X}{:02X}{:02X}", - ((rgb.r * 255.) as u32), - ((rgb.g * 255.) as u32), - ((rgb.b * 255.) as u32), - ((self.a * 255.) as u32) - ); - } - - format!( - "#{:02X}{:02X}{:02X}", - ((rgb.r * 255.) as u32), - ((rgb.g * 255.) as u32), - ((rgb.b * 255.) as u32) - ) - } - - fn parse_hex(hex: &str) -> Result { - let hex = hex.trim_start_matches('#'); - let len = hex.len(); - if len != 6 && len != 8 { - return Err(anyhow::anyhow!("invalid hex color")); - } - - let r = u8::from_str_radix(&hex[0..2], 16)? as f32 / 255.; - let g = u8::from_str_radix(&hex[2..4], 16)? as f32 / 255.; - let b = u8::from_str_radix(&hex[4..6], 16)? as f32 / 255.; - let a = if len == 8 { - u8::from_str_radix(&hex[6..8], 16)? as f32 / 255. - } else { - 1. - }; - - let v = gpui::Rgba { r, g, b, a }; - let color: Hsla = v.into(); - Ok(color) - } - - fn hue(&self, hue: f32) -> Self { - let mut color = *self; - color.h = hue.clamp(0., 1.); - color - } - - fn saturation(&self, saturation: f32) -> Self { - let mut color = *self; - color.s = saturation.clamp(0., 1.); - color - } - - fn lightness(&self, lightness: f32) -> Self { - let mut color = *self; - color.l = lightness.clamp(0., 1.); - color - } -} - -pub(crate) static DEFAULT_COLORS: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { - serde_json::from_str(include_str!("./default-colors.json")) - .expect("failed to parse default-colors.json") - }); - -type ColorScales = HashMap; - -mod color_scales { - use std::collections::HashMap; - - use super::{ColorScales, ShadcnColor}; - - use serde::de::{Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let mut map = HashMap::new(); - for color in Vec::::deserialize(deserializer)? { - map.insert(color.scale, color); - } - Ok(map) - } -} - -/// Enum representing the available color names. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ColorName { - White, - Black, - Neutral, - Gray, - Red, - Orange, - Amber, - Yellow, - Lime, - Green, - Emerald, - Teal, - Cyan, - Sky, - Blue, - Indigo, - Violet, - Purple, - Fuchsia, - Pink, - Rose, -} - -impl Display for ColorName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -} - -// Strict color name parser. -impl TryFrom<&str> for ColorName { - type Error = anyhow::Error; - fn try_from(value: &str) -> std::result::Result { - match value.to_lowercase().as_str() { - "white" => Ok(ColorName::White), - "black" => Ok(ColorName::Black), - "neutral" => Ok(ColorName::Neutral), - "gray" => Ok(ColorName::Gray), - "red" => Ok(ColorName::Red), - "orange" => Ok(ColorName::Orange), - "amber" => Ok(ColorName::Amber), - "yellow" => Ok(ColorName::Yellow), - "lime" => Ok(ColorName::Lime), - "green" => Ok(ColorName::Green), - "emerald" => Ok(ColorName::Emerald), - "teal" => Ok(ColorName::Teal), - "cyan" => Ok(ColorName::Cyan), - "sky" => Ok(ColorName::Sky), - "blue" => Ok(ColorName::Blue), - "indigo" => Ok(ColorName::Indigo), - "violet" => Ok(ColorName::Violet), - "purple" => Ok(ColorName::Purple), - "fuchsia" => Ok(ColorName::Fuchsia), - "pink" => Ok(ColorName::Pink), - "rose" => Ok(ColorName::Rose), - _ => Err(anyhow::anyhow!("Invalid color name")), - } - } -} - -impl TryFrom for ColorName { - type Error = anyhow::Error; - fn try_from(value: SharedString) -> std::result::Result { - value.as_ref().try_into() - } -} - -impl ColorName { - /// Returns all available color names. - pub fn all() -> [Self; 19] { - [ - ColorName::Neutral, - ColorName::Gray, - ColorName::Red, - ColorName::Orange, - ColorName::Amber, - ColorName::Yellow, - ColorName::Lime, - ColorName::Green, - ColorName::Emerald, - ColorName::Teal, - ColorName::Cyan, - ColorName::Sky, - ColorName::Blue, - ColorName::Indigo, - ColorName::Violet, - ColorName::Purple, - ColorName::Fuchsia, - ColorName::Pink, - ColorName::Rose, - ] - } - - /// Returns the color for the given scale. - /// - /// The `scale` is any of `[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]` - /// falls back to 500 if out of range. - pub fn scale(&self, scale: usize) -> Hsla { - if self == &ColorName::White { - return DEFAULT_COLORS.white.hsla; - } - if self == &ColorName::Black { - return DEFAULT_COLORS.black.hsla; - } - - let colors = match self { - ColorName::Neutral => &DEFAULT_COLORS.neutral, - ColorName::Gray => &DEFAULT_COLORS.gray, - ColorName::Red => &DEFAULT_COLORS.red, - ColorName::Orange => &DEFAULT_COLORS.orange, - ColorName::Amber => &DEFAULT_COLORS.amber, - ColorName::Yellow => &DEFAULT_COLORS.yellow, - ColorName::Lime => &DEFAULT_COLORS.lime, - ColorName::Green => &DEFAULT_COLORS.green, - ColorName::Emerald => &DEFAULT_COLORS.emerald, - ColorName::Teal => &DEFAULT_COLORS.teal, - ColorName::Cyan => &DEFAULT_COLORS.cyan, - ColorName::Sky => &DEFAULT_COLORS.sky, - ColorName::Blue => &DEFAULT_COLORS.blue, - ColorName::Indigo => &DEFAULT_COLORS.indigo, - ColorName::Violet => &DEFAULT_COLORS.violet, - ColorName::Purple => &DEFAULT_COLORS.purple, - ColorName::Fuchsia => &DEFAULT_COLORS.fuchsia, - ColorName::Pink => &DEFAULT_COLORS.pink, - ColorName::Rose => &DEFAULT_COLORS.rose, - _ => unreachable!(), - }; - - if let Some(color) = colors.get(&scale) { - color.hsla - } else { - colors.get(&500).unwrap().hsla - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -pub(crate) struct ShadcnColors { - pub(crate) black: ShadcnColor, - pub(crate) white: ShadcnColor, - #[serde(with = "color_scales")] - pub(crate) slate: ColorScales, - #[serde(with = "color_scales")] - pub(crate) gray: ColorScales, - #[serde(with = "color_scales")] - pub(crate) zinc: ColorScales, - #[serde(with = "color_scales")] - pub(crate) neutral: ColorScales, - #[serde(with = "color_scales")] - pub(crate) stone: ColorScales, - #[serde(with = "color_scales")] - pub(crate) red: ColorScales, - #[serde(with = "color_scales")] - pub(crate) orange: ColorScales, - #[serde(with = "color_scales")] - pub(crate) amber: ColorScales, - #[serde(with = "color_scales")] - pub(crate) yellow: ColorScales, - #[serde(with = "color_scales")] - pub(crate) lime: ColorScales, - #[serde(with = "color_scales")] - pub(crate) green: ColorScales, - #[serde(with = "color_scales")] - pub(crate) emerald: ColorScales, - #[serde(with = "color_scales")] - pub(crate) teal: ColorScales, - #[serde(with = "color_scales")] - pub(crate) cyan: ColorScales, - #[serde(with = "color_scales")] - pub(crate) sky: ColorScales, - #[serde(with = "color_scales")] - pub(crate) blue: ColorScales, - #[serde(with = "color_scales")] - pub(crate) indigo: ColorScales, - #[serde(with = "color_scales")] - pub(crate) violet: ColorScales, - #[serde(with = "color_scales")] - pub(crate) purple: ColorScales, - #[serde(with = "color_scales")] - pub(crate) fuchsia: ColorScales, - #[serde(with = "color_scales")] - pub(crate) pink: ColorScales, - #[serde(with = "color_scales")] - pub(crate) rose: ColorScales, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)] -pub(crate) struct ShadcnColor { - #[serde(default)] - pub(crate) scale: usize, - #[serde(deserialize_with = "from_hsl_channel", alias = "hslChannel")] - pub(crate) hsla: Hsla, -} - -/// Deserialize Hsla from a string in the format "210 40% 98%" -fn from_hsl_channel<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer).unwrap(); - - let mut parts = s.split_whitespace(); - if parts.clone().count() != 3 { - return Err(D::Error::custom( - "expected hslChannel has 3 parts, e.g: '210 40% 98%'", - )); - } - - fn parse_number(s: &str) -> f32 { - s.trim_end_matches('%') - .parse() - .expect("failed to parse number") - } - - let (h, s, l) = ( - parse_number(parts.next().unwrap()), - parse_number(parts.next().unwrap()), - parse_number(parts.next().unwrap()), - ); - - Ok(hsl(h, s, l)) -} - -macro_rules! color_method { - ($color:tt, $scale:tt) => { - paste::paste! { - #[inline] - #[allow(unused)] - pub fn [<$color _ $scale>]() -> Hsla { - if let Some(color) = DEFAULT_COLORS.$color.get(&($scale as usize)) { - return color.hsla; - } - - black() - } - } - }; -} - -macro_rules! color_methods { - ($color:tt) => { - paste::paste! { - /// Get color by scale number. - /// - /// The possible scale numbers are: - /// 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 - /// - /// If the scale number is not found, it will return black color. - #[inline] - pub fn [<$color>](scale: usize) -> Hsla { - if let Some(color) = DEFAULT_COLORS.$color.get(&scale) { - return color.hsla; - } - - black() - } - } - - color_method!($color, 50); - color_method!($color, 100); - color_method!($color, 200); - color_method!($color, 300); - color_method!($color, 400); - color_method!($color, 500); - color_method!($color, 600); - color_method!($color, 700); - color_method!($color, 800); - color_method!($color, 900); - color_method!($color, 950); - }; -} - -pub fn black() -> Hsla { - DEFAULT_COLORS.black.hsla -} - -pub fn white() -> Hsla { - DEFAULT_COLORS.white.hsla -} - -color_methods!(slate); -color_methods!(gray); -color_methods!(zinc); -color_methods!(neutral); -color_methods!(stone); -color_methods!(red); -color_methods!(orange); -color_methods!(amber); -color_methods!(yellow); -color_methods!(lime); -color_methods!(green); -color_methods!(emerald); -color_methods!(teal); -color_methods!(cyan); -color_methods!(sky); -color_methods!(blue); -color_methods!(indigo); -color_methods!(violet); -color_methods!(purple); -color_methods!(fuchsia); -color_methods!(pink); -color_methods!(rose); - -/// Try to parse the color, HEX or [Tailwind Color](https://tailwindcss.com/docs/colors) expression. -/// -/// # Parameter `color` should be one string value listed below: -/// -/// - `#RRGGBB` - The HEX color string. -/// - `#RRGGBBAA` - The HEX color string with alpha. -/// -/// Or the Tailwind Color format: -/// -/// - `name` - The color name `black`, `white`, or any other defined in `crate::color`. -/// - `name-scale` - The color name with scale. -/// - `name/opacity` - The color name with opacity, `opacity` should be an integer between 0 and 100. -/// - `name-scale/opacity` - The color name with scale and opacity. -/// -pub fn try_parse_color(color: &str) -> Result { - if color.starts_with("#") { - let rgba = gpui::Rgba::try_from(color)?; - return Ok(rgba.into()); - } - - let mut name = String::new(); - let mut scale = None; - let mut opacity = None; - // 0: name, 1: scale, 2: opacity - let mut state = 0; - let mut part = String::new(); - - for c in color.chars() { - match c { - '-' if state == 0 => { - name = std::mem::take(&mut part); - state = 1; - } - '/' if state <= 1 => { - if state == 0 { - name = std::mem::take(&mut part); - } else if state == 1 { - scale = part.parse::().ok(); - part.clear(); - } - state = 2; - } - _ => part.push(c), - } - } - - match state { - 0 => name = part, - 1 => scale = part.parse::().ok(), - 2 => opacity = part.parse::().ok(), - _ => {} - } - - if name.is_empty() { - return Err(anyhow!("Empty color name")); - } - - let mut hsla = match name.as_str() { - "black" => Ok::(crate::black()), - "white" => Ok(crate::white()), - _ => { - let color_name = ColorName::try_from(name.as_str())?; - if let Some(scale) = scale { - Ok(color_name.scale(scale)) - } else { - Ok(color_name.scale(500)) - } - } - }?; - - if let Some(opacity) = opacity { - if opacity > 100. { - return Err(anyhow!("Invalid color opacity")); - } - hsla = hsla.opacity(opacity / 100.); - } - - Ok(hsla) -} - -#[cfg(test)] -mod tests { - use gpui::{rgb, rgba}; - - use super::*; - - #[test] - fn test_default_colors() { - assert_eq!(white(), hsl(0.0, 0.0, 100.0)); - assert_eq!(black(), hsl(0.0, 0.0, 0.0)); - - assert_eq!(slate_50(), hsl(210.0, 40.0, 98.0)); - assert_eq!(slate_100(), hsl(210.0, 40.0, 96.1)); - assert_eq!(slate_900(), hsl(222.2, 47.4, 11.2)); - - assert_eq!(red_50(), hsl(0.0, 85.7, 97.3)); - assert_eq!(yellow_100(), hsl(54.9, 96.7, 88.0)); - assert_eq!(green_200(), hsl(141.0, 78.9, 85.1)); - assert_eq!(cyan_300(), hsl(187.0, 92.4, 69.0)); - assert_eq!(blue_400(), hsl(213.1, 93.9, 67.8)); - assert_eq!(indigo_500(), hsl(238.7, 83.5, 66.7)); - } - - #[test] - fn test_to_hex_string() { - let color: Hsla = rgb(0xf8fafc).into(); - assert_eq!(color.to_hex(), "#F8FAFC"); - - let color: Hsla = rgb(0xfef2f2).into(); - assert_eq!(color.to_hex(), "#FEF2F2"); - - let color: Hsla = rgba(0x0413fcaa).into(); - assert_eq!(color.to_hex(), "#0413FCAA"); - } - - #[test] - fn test_from_hex_string() { - let color: Hsla = Hsla::parse_hex("#F8FAFC").unwrap(); - assert_eq!(color, rgb(0xf8fafc).into()); - - let color: Hsla = Hsla::parse_hex("#FEF2F2").unwrap(); - assert_eq!(color, rgb(0xfef2f2).into()); - - let color: Hsla = Hsla::parse_hex("#0413FCAA").unwrap(); - assert_eq!(color, rgba(0x0413fcaa).into()); - } - - #[test] - fn test_lighten() { - let color = super::hsl(240.0, 5.0, 30.0); - let color = color.lighten(0.5); - assert_eq!(color.l, 0.45000002); - let color = color.lighten(0.5); - assert_eq!(color.l, 0.675); - let color = color.lighten(0.1); - assert_eq!(color.l, 0.7425); - } - - #[test] - fn test_darken() { - let color = super::hsl(240.0, 5.0, 96.0); - let color = color.darken(0.5); - assert_eq!(color.l, 0.48); - let color = color.darken(0.5); - assert_eq!(color.l, 0.24); - } - - #[test] - fn test_mix() { - let red = Hsla::parse_hex("#FF0000").unwrap(); - let blue = Hsla::parse_hex("#0000FF").unwrap(); - let green = Hsla::parse_hex("#00FF00").unwrap(); - let yellow = Hsla::parse_hex("#FFFF00").unwrap(); - - assert_eq!(red.mix(blue, 0.5).to_hex(), "#FF00FF"); - assert_eq!(green.mix(red, 0.5).to_hex(), "#FFFF00"); - assert_eq!(blue.mix(yellow, 0.2).to_hex(), "#0098FF"); - } - - #[test] - fn test_mix_oklab() { - let red = Hsla::parse_hex("#FF0000").unwrap(); - let blue = Hsla::parse_hex("#0000FF").unwrap(); - let transparent = gpui::Hsla { - h: 0.0, - s: 0.0, - l: 0.0, - a: 0.0, - }; - - // Test mixing red with transparent (similar to CSS color-mix example) - // color-mix(in oklab, red 20%, transparent) should give red with 20% opacity - let result = red.mix_oklab(transparent, 0.2); - assert!((result.a - 0.2).abs() < 0.01); // Alpha should be 20% - - // The color should remain red (hue should be preserved) - let rgb_result = result.to_rgb(); - let rgb_red = red.to_rgb(); - // Allow some tolerance due to color space conversions - assert!( - (rgb_result.r - rgb_red.r).abs() < 0.05, - "Red channel should be preserved" - ); - assert!(rgb_result.g < 0.05, "Green channel should be near 0"); - assert!(rgb_result.b < 0.05, "Blue channel should be near 0"); - - // Test basic color mixing in Oklab space - let purple = red.mix_oklab(blue, 0.5); - // Oklab mixing should produce different results than HSL mixing - let purple_hsl = red.mix(blue, 0.5); - assert_ne!(purple.to_hex(), purple_hsl.to_hex()); - - // Test factor boundaries (allowing small floating point errors) - let result_0 = red.mix_oklab(blue, 0.0); - let result_1 = red.mix_oklab(blue, 1.0); - - // Check that result is close to expected (within 1 color unit per channel) - let rgb_0 = result_0.to_rgb(); - let rgb_blue = blue.to_rgb(); - assert!((rgb_0.r - rgb_blue.r).abs() < 0.01); - assert!((rgb_0.g - rgb_blue.g).abs() < 0.01); - assert!((rgb_0.b - rgb_blue.b).abs() < 0.01); - - let rgb_1 = result_1.to_rgb(); - let rgb_red = red.to_rgb(); - assert!((rgb_1.r - rgb_red.r).abs() < 0.01); - assert!((rgb_1.g - rgb_red.g).abs() < 0.01); - assert!((rgb_1.b - rgb_red.b).abs() < 0.01); - } - - #[test] - fn test_color_name() { - assert_eq!(ColorName::Purple.to_string(), "Purple"); - assert_eq!(format!("{}", ColorName::Green), "Green"); - assert_eq!(format!("{:?}", ColorName::Yellow), "Yellow"); - - let color = ColorName::Green; - assert_eq!(color.scale(500).to_hex(), "#21C55E"); - assert_eq!(color.scale(1500).to_hex(), "#21C55E"); - - for name in ColorName::all().iter() { - let name1: ColorName = name.to_string().as_str().try_into().unwrap(); - assert_eq!(name1, *name); - } - } - - #[test] - fn test_h_s_l() { - let color = hsl(260., 94., 80.); - assert_eq!(color.hue(200. / 360.), hsl(200., 94., 80.)); - assert_eq!(color.saturation(74. / 100.), hsl(260., 74., 80.)); - assert_eq!(color.lightness(74. / 100.), hsl(260., 94., 74.)); - } - - #[test] - fn test_try_parse_color() { - assert_eq!( - try_parse_color("#F2F200").ok(), - Some(hsla(0.16666667, 1., 0.4745098, 1.0)) - ); - assert_eq!( - try_parse_color("#00f21888").ok(), - Some(hsla(0.34986225, 1.0, 0.4745098, 0.53333336)) - ); - assert_eq!(try_parse_color("black").ok(), Some(crate::black())); - assert_eq!(try_parse_color("white-800").ok(), Some(crate::white())); - assert_eq!(try_parse_color("red").ok(), Some(crate::red_500())); - assert_eq!(try_parse_color("blue-600").ok(), Some(crate::blue_600())); - assert_eq!( - try_parse_color("pink/33").ok(), - Some(crate::pink_500().opacity(0.33)) - ); - assert_eq!( - try_parse_color("orange-300/66").ok(), - Some(crate::orange_300().opacity(0.66)) - ); - } -} diff --git a/vendor/gpui-component/crates/ui/src/theme/default-colors.json b/vendor/gpui-component/crates/ui/src/theme/default-colors.json deleted file mode 100644 index 3ff5eb12..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/default-colors.json +++ /dev/null @@ -1,2244 +0,0 @@ -{ - "inherit": "inherit", - "_doc": "https://github.com/shadcn-ui/ui/blob/a46eea77a6680fc40cee9fa1f209e77931068f35/apps/v4/public/r/colors/index.json", - "current": "currentColor", - "transparent": "transparent", - "black": { - "hex": "#000000", - "rgb": "rgb(0,0,0)", - "hsl": "hsl(0,0%,0%)", - "oklch": "oklch(0.00,0.00,0)", - "rgbChannel": "0 0 0", - "hslChannel": "0 0% 0%" - }, - "white": { - "hex": "#ffffff", - "rgb": "rgb(255,255,255)", - "hsl": "hsl(0,0%,100%)", - "oklch": "oklch(1.00,0.00,0)", - "rgbChannel": "255 255 255", - "hslChannel": "0 0% 100%" - }, - "slate": [ - { - "scale": 50, - "hex": "#f8fafc", - "rgb": "rgb(248,250,252)", - "hsl": "hsl(210,40%,98%)", - "oklch": "oklch(0.98,0.00,248)", - "rgbChannel": "248 250 252", - "hslChannel": "210 40% 98%" - }, - { - "scale": 100, - "hex": "#f1f5f9", - "rgb": "rgb(241,245,249)", - "hsl": "hsl(210,40%,96.1%)", - "oklch": "oklch(0.97,0.01,248)", - "rgbChannel": "241 245 249", - "hslChannel": "210 40% 96.1%" - }, - { - "scale": 200, - "hex": "#e2e8f0", - "rgb": "rgb(226,232,240)", - "hsl": "hsl(214.3,31.8%,91.4%)", - "oklch": "oklch(0.93,0.01,256)", - "rgbChannel": "226 232 240", - "hslChannel": "214.3 31.8% 91.4%" - }, - { - "scale": 300, - "hex": "#cbd5e1", - "rgb": "rgb(203,213,225)", - "hsl": "hsl(212.7,26.8%,83.9%)", - "oklch": "oklch(0.87,0.02,253)", - "rgbChannel": "203 213 225", - "hslChannel": "212.7 26.8% 83.9%" - }, - { - "scale": 400, - "hex": "#94a3b8", - "rgb": "rgb(148,163,184)", - "hsl": "hsl(215,20.2%,65.1%)", - "oklch": "oklch(0.71,0.04,257)", - "rgbChannel": "148 163 184", - "hslChannel": "215 20.2% 65.1%" - }, - { - "scale": 500, - "hex": "#64748b", - "rgb": "rgb(100,116,139)", - "hsl": "hsl(215.4,16.3%,46.9%)", - "oklch": "oklch(0.55,0.04,257)", - "rgbChannel": "100 116 139", - "hslChannel": "215.4 16.3% 46.9%" - }, - { - "scale": 600, - "hex": "#475569", - "rgb": "rgb(71,85,105)", - "hsl": "hsl(215.3,19.3%,34.5%)", - "oklch": "oklch(0.45,0.04,257)", - "rgbChannel": "71 85 105", - "hslChannel": "215.3 19.3% 34.5%" - }, - { - "scale": 700, - "hex": "#334155", - "rgb": "rgb(51,65,85)", - "hsl": "hsl(215.3,25%,26.7%)", - "oklch": "oklch(0.37,0.04,257)", - "rgbChannel": "51 65 85", - "hslChannel": "215.3 25% 26.7%" - }, - { - "scale": 800, - "hex": "#1e293b", - "rgb": "rgb(30,41,59)", - "hsl": "hsl(217.2,32.6%,17.5%)", - "oklch": "oklch(0.28,0.04,260)", - "rgbChannel": "30 41 59", - "hslChannel": "217.2 32.6% 17.5%" - }, - { - "scale": 900, - "hex": "#0f172a", - "rgb": "rgb(15,23,42)", - "hsl": "hsl(222.2,47.4%,11.2%)", - "oklch": "oklch(0.21,0.04,266)", - "rgbChannel": "15 23 42", - "hslChannel": "222.2 47.4% 11.2%" - }, - { - "scale": 950, - "hex": "#020617", - "rgb": "rgb(2,6,23)", - "hsl": "hsl(222.2,84%,4.9%)", - "oklch": "oklch(0.13,0.04,265)", - "rgbChannel": "2 6 23", - "hslChannel": "222.2 84% 4.9%" - } - ], - "gray": [ - { - "scale": 50, - "hex": "#f9fafb", - "rgb": "rgb(249,250,251)", - "hsl": "hsl(210,20%,98%)", - "oklch": "oklch(0.98,0.00,248)", - "rgbChannel": "249 250 251", - "hslChannel": "210 20% 98%" - }, - { - "scale": 100, - "hex": "#f3f4f6", - "rgb": "rgb(243,244,246)", - "hsl": "hsl(220,14.3%,95.9%)", - "oklch": "oklch(0.97,0.00,265)", - "rgbChannel": "243 244 246", - "hslChannel": "220 14.3% 95.9%" - }, - { - "scale": 200, - "hex": "#e5e7eb", - "rgb": "rgb(229,231,235)", - "hsl": "hsl(220,13%,91%)", - "oklch": "oklch(0.93,0.01,265)", - "rgbChannel": "229 231 235", - "hslChannel": "220 13% 91%" - }, - { - "scale": 300, - "hex": "#d1d5db", - "rgb": "rgb(209,213,219)", - "hsl": "hsl(216,12.2%,83.9%)", - "oklch": "oklch(0.87,0.01,258)", - "rgbChannel": "209 213 219", - "hslChannel": "216 12.2% 83.9%" - }, - { - "scale": 400, - "hex": "#9ca3af", - "rgb": "rgb(156,163,175)", - "hsl": "hsl(217.9,10.6%,64.9%)", - "oklch": "oklch(0.71,0.02,261)", - "rgbChannel": "156 163 175", - "hslChannel": "217.9 10.6% 64.9%" - }, - { - "scale": 500, - "hex": "#6b7280", - "rgb": "rgb(107,114,128)", - "hsl": "hsl(220,8.9%,46.1%)", - "oklch": "oklch(0.55,0.02,264)", - "rgbChannel": "107 114 128", - "hslChannel": "220 8.9% 46.1%" - }, - { - "scale": 600, - "hex": "#4b5563", - "rgb": "rgb(75,85,99)", - "hsl": "hsl(215,13.8%,34.1%)", - "oklch": "oklch(0.45,0.03,257)", - "rgbChannel": "75 85 99", - "hslChannel": "215 13.8% 34.1%" - }, - { - "scale": 700, - "hex": "#374151", - "rgb": "rgb(55,65,81)", - "hsl": "hsl(216.9,19.1%,26.7%)", - "oklch": "oklch(0.37,0.03,260)", - "rgbChannel": "55 65 81", - "hslChannel": "216.9 19.1% 26.7%" - }, - { - "scale": 800, - "hex": "#1f2937", - "rgb": "rgb(31,41,55)", - "hsl": "hsl(215,27.9%,16.9%)", - "oklch": "oklch(0.28,0.03,257)", - "rgbChannel": "31 41 55", - "hslChannel": "215 27.9% 16.9%" - }, - { - "scale": 900, - "hex": "#111827", - "rgb": "rgb(17,24,39)", - "hsl": "hsl(220.9,39.3%,11%)", - "oklch": "oklch(0.21,0.03,265)", - "rgbChannel": "17 24 39", - "hslChannel": "220.9 39.3% 11%" - }, - { - "scale": 950, - "hex": "#030712", - "rgb": "rgb(3,7,18)", - "hsl": "hsl(224,71.4%,4.1%)", - "oklch": "oklch(0.13,0.03,262)", - "rgbChannel": "3 7 18", - "hslChannel": "224 71.4% 4.1%" - } - ], - "zinc": [ - { - "scale": 50, - "hex": "#fafafa", - "rgb": "rgb(250,250,250)", - "hsl": "hsl(0,0%,98%)", - "oklch": "oklch(0.99,0.00,0)", - "rgbChannel": "250 250 250", - "hslChannel": "0 0% 98%" - }, - { - "scale": 100, - "hex": "#f4f4f5", - "rgb": "rgb(244,244,245)", - "hsl": "hsl(240,4.8%,95.9%)", - "oklch": "oklch(0.97,0.00,286)", - "rgbChannel": "244 244 245", - "hslChannel": "240 4.8% 95.9%" - }, - { - "scale": 200, - "hex": "#e4e4e7", - "rgb": "rgb(228,228,231)", - "hsl": "hsl(240,5.9%,90%)", - "oklch": "oklch(0.92,0.00,286)", - "rgbChannel": "228 228 231", - "hslChannel": "240 5.9% 90%" - }, - { - "scale": 300, - "hex": "#d4d4d8", - "rgb": "rgb(212,212,216)", - "hsl": "hsl(240,4.9%,83.9%)", - "oklch": "oklch(0.87,0.01,286)", - "rgbChannel": "212 212 216", - "hslChannel": "240 4.9% 83.9%" - }, - { - "scale": 400, - "hex": "#a1a1aa", - "rgb": "rgb(161,161,170)", - "hsl": "hsl(240,5%,64.9%)", - "oklch": "oklch(0.71,0.01,286)", - "rgbChannel": "161 161 170", - "hslChannel": "240 5% 64.9%" - }, - { - "scale": 500, - "hex": "#71717a", - "rgb": "rgb(113,113,122)", - "hsl": "hsl(240,3.8%,46.1%)", - "oklch": "oklch(0.55,0.01,286)", - "rgbChannel": "113 113 122", - "hslChannel": "240 3.8% 46.1%" - }, - { - "scale": 600, - "hex": "#52525b", - "rgb": "rgb(82,82,91)", - "hsl": "hsl(240,5.2%,33.9%)", - "oklch": "oklch(0.44,0.01,286)", - "rgbChannel": "82 82 91", - "hslChannel": "240 5.2% 33.9%" - }, - { - "scale": 700, - "hex": "#3f3f46", - "rgb": "rgb(63,63,70)", - "hsl": "hsl(240,5.3%,26.1%)", - "oklch": "oklch(0.37,0.01,286)", - "rgbChannel": "63 63 70", - "hslChannel": "240 5.3% 26.1%" - }, - { - "scale": 800, - "hex": "#27272a", - "rgb": "rgb(39,39,42)", - "hsl": "hsl(240,3.7%,15.9%)", - "oklch": "oklch(0.27,0.01,286)", - "rgbChannel": "39 39 42", - "hslChannel": "240 3.7% 15.9%" - }, - { - "scale": 900, - "hex": "#18181b", - "rgb": "rgb(24,24,27)", - "hsl": "hsl(240,5.9%,10%)", - "oklch": "oklch(0.21,0.01,286)", - "rgbChannel": "24 24 27", - "hslChannel": "240 5.9% 10%" - }, - { - "scale": 950, - "hex": "#09090b", - "rgb": "rgb(9,9,11)", - "hsl": "hsl(240,10%,3.9%)", - "oklch": "oklch(0.14,0.00,286)", - "rgbChannel": "9 9 11", - "hslChannel": "240 10% 3.9%" - } - ], - "neutral": [ - { - "scale": 50, - "hex": "#fafafa", - "rgb": "rgb(250,250,250)", - "hsl": "hsl(0,0%,98%)", - "oklch": "oklch(0.99,0.00,0)", - "rgbChannel": "250 250 250", - "hslChannel": "0 0% 98%" - }, - { - "scale": 100, - "hex": "#f5f5f5", - "rgb": "rgb(245,245,245)", - "hsl": "hsl(0,0%,96.1%)", - "oklch": "oklch(0.97,0.00,0)", - "rgbChannel": "245 245 245", - "hslChannel": "0 0% 96.1%" - }, - { - "scale": 200, - "hex": "#e5e5e5", - "rgb": "rgb(229,229,229)", - "hsl": "hsl(0,0%,89.8%)", - "oklch": "oklch(0.92,0.00,0)", - "rgbChannel": "229 229 229", - "hslChannel": "0 0% 89.8%" - }, - { - "scale": 300, - "hex": "#d4d4d4", - "rgb": "rgb(212,212,212)", - "hsl": "hsl(0,0%,83.1%)", - "oklch": "oklch(0.87,0.00,0)", - "rgbChannel": "212 212 212", - "hslChannel": "0 0% 83.1%" - }, - { - "scale": 400, - "hex": "#a3a3a3", - "rgb": "rgb(163,163,163)", - "hsl": "hsl(0,0%,63.9%)", - "oklch": "oklch(0.72,0.00,0)", - "rgbChannel": "163 163 163", - "hslChannel": "0 0% 63.9%" - }, - { - "scale": 500, - "hex": "#737373", - "rgb": "rgb(115,115,115)", - "hsl": "hsl(0,0%,45.1%)", - "oklch": "oklch(0.56,0.00,0)", - "rgbChannel": "115 115 115", - "hslChannel": "0 0% 45.1%" - }, - { - "scale": 600, - "hex": "#525252", - "rgb": "rgb(82,82,82)", - "hsl": "hsl(0,0%,32.2%)", - "oklch": "oklch(0.44,0.00,0)", - "rgbChannel": "82 82 82", - "hslChannel": "0 0% 32.2%" - }, - { - "scale": 700, - "hex": "#404040", - "rgb": "rgb(64,64,64)", - "hsl": "hsl(0,0%,25.1%)", - "oklch": "oklch(0.37,0.00,0)", - "rgbChannel": "64 64 64", - "hslChannel": "0 0% 25.1%" - }, - { - "scale": 800, - "hex": "#262626", - "rgb": "rgb(38,38,38)", - "hsl": "hsl(0,0%,14.9%)", - "oklch": "oklch(0.27,0.00,0)", - "rgbChannel": "38 38 38", - "hslChannel": "0 0% 14.9%" - }, - { - "scale": 900, - "hex": "#171717", - "rgb": "rgb(23,23,23)", - "hsl": "hsl(0,0%,9%)", - "oklch": "oklch(0.20,0.00,0)", - "rgbChannel": "23 23 23", - "hslChannel": "0 0% 9%" - }, - { - "scale": 950, - "hex": "#0a0a0a", - "rgb": "rgb(10,10,10)", - "hsl": "hsl(0,0%,3.9%)", - "oklch": "oklch(0.14,0.00,0)", - "rgbChannel": "10 10 10", - "hslChannel": "0 0% 3.9%" - } - ], - "stone": [ - { - "scale": 50, - "hex": "#fafaf9", - "rgb": "rgb(250,250,249)", - "hsl": "hsl(60,9.1%,97.8%)", - "oklch": "oklch(0.98,0.00,106)", - "rgbChannel": "250 250 249", - "hslChannel": "60 9.1% 97.8%" - }, - { - "scale": 100, - "hex": "#f5f5f4", - "rgb": "rgb(245,245,244)", - "hsl": "hsl(60,4.8%,95.9%)", - "oklch": "oklch(0.97,0.00,106)", - "rgbChannel": "245 245 244", - "hslChannel": "60 4.8% 95.9%" - }, - { - "scale": 200, - "hex": "#e7e5e4", - "rgb": "rgb(231,229,228)", - "hsl": "hsl(20,5.9%,90%)", - "oklch": "oklch(0.92,0.00,49)", - "rgbChannel": "231 229 228", - "hslChannel": "20 5.9% 90%" - }, - { - "scale": 300, - "hex": "#d6d3d1", - "rgb": "rgb(214,211,209)", - "hsl": "hsl(24,5.7%,82.9%)", - "oklch": "oklch(0.87,0.00,56)", - "rgbChannel": "214 211 209", - "hslChannel": "24 5.7% 82.9%" - }, - { - "scale": 400, - "hex": "#a8a29e", - "rgb": "rgb(168,162,158)", - "hsl": "hsl(24,5.4%,63.9%)", - "oklch": "oklch(0.72,0.01,56)", - "rgbChannel": "168 162 158", - "hslChannel": "24 5.4% 63.9%" - }, - { - "scale": 500, - "hex": "#78716c", - "rgb": "rgb(120,113,108)", - "hsl": "hsl(25,5.3%,44.7%)", - "oklch": "oklch(0.55,0.01,58)", - "rgbChannel": "120 113 108", - "hslChannel": "25 5.3% 44.7%" - }, - { - "scale": 600, - "hex": "#57534e", - "rgb": "rgb(87,83,78)", - "hsl": "hsl(33.3,5.5%,32.4%)", - "oklch": "oklch(0.44,0.01,74)", - "rgbChannel": "87 83 78", - "hslChannel": "33.3 5.5% 32.4%" - }, - { - "scale": 700, - "hex": "#44403c", - "rgb": "rgb(68,64,60)", - "hsl": "hsl(30,6.3%,25.1%)", - "oklch": "oklch(0.37,0.01,68)", - "rgbChannel": "68 64 60", - "hslChannel": "30 6.3% 25.1%" - }, - { - "scale": 800, - "hex": "#292524", - "rgb": "rgb(41,37,36)", - "hsl": "hsl(12,6.5%,15.1%)", - "oklch": "oklch(0.27,0.01,34)", - "rgbChannel": "41 37 36", - "hslChannel": "12 6.5% 15.1%" - }, - { - "scale": 900, - "hex": "#1c1917", - "rgb": "rgb(28,25,23)", - "hsl": "hsl(24,9.8%,10%)", - "oklch": "oklch(0.22,0.01,56)", - "rgbChannel": "28 25 23", - "hslChannel": "24 9.8% 10%" - }, - { - "scale": 950, - "hex": "#0c0a09", - "rgb": "rgb(12,10,9)", - "hsl": "hsl(20,14.3%,4.1%)", - "oklch": "oklch(0.15,0.00,49)", - "rgbChannel": "12 10 9", - "hslChannel": "20 14.3% 4.1%" - } - ], - "red": [ - { - "scale": 50, - "hex": "#fef2f2", - "rgb": "rgb(254,242,242)", - "hsl": "hsl(0,85.7%,97.3%)", - "oklch": "oklch(0.97,0.01,17)", - "rgbChannel": "254 242 242", - "hslChannel": "0 85.7% 97.3%" - }, - { - "scale": 100, - "hex": "#fee2e2", - "rgb": "rgb(254,226,226)", - "hsl": "hsl(0,93.3%,94.1%)", - "oklch": "oklch(0.94,0.03,18)", - "rgbChannel": "254 226 226", - "hslChannel": "0 93.3% 94.1%" - }, - { - "scale": 200, - "hex": "#fecaca", - "rgb": "rgb(254,202,202)", - "hsl": "hsl(0,96.3%,89.4%)", - "oklch": "oklch(0.88,0.06,18)", - "rgbChannel": "254 202 202", - "hslChannel": "0 96.3% 89.4%" - }, - { - "scale": 300, - "hex": "#fca5a5", - "rgb": "rgb(252,165,165)", - "hsl": "hsl(0,93.5%,81.8%)", - "oklch": "oklch(0.81,0.10,20)", - "rgbChannel": "252 165 165", - "hslChannel": "0 93.5% 81.8%" - }, - { - "scale": 400, - "hex": "#f87171", - "rgb": "rgb(248,113,113)", - "hsl": "hsl(0,90.6%,70.8%)", - "oklch": "oklch(0.71,0.17,22)", - "rgbChannel": "248 113 113", - "hslChannel": "0 90.6% 70.8%" - }, - { - "scale": 500, - "hex": "#ef4444", - "rgb": "rgb(239,68,68)", - "hsl": "hsl(0,84.2%,60.2%)", - "oklch": "oklch(0.64,0.21,25)", - "rgbChannel": "239 68 68", - "hslChannel": "0 84.2% 60.2%" - }, - { - "scale": 600, - "hex": "#dc2626", - "rgb": "rgb(220,38,38)", - "hsl": "hsl(0,72.2%,50.6%)", - "oklch": "oklch(0.58,0.22,27)", - "rgbChannel": "220 38 38", - "hslChannel": "0 72.2% 50.6%" - }, - { - "scale": 700, - "hex": "#b91c1c", - "rgb": "rgb(185,28,28)", - "hsl": "hsl(0,73.7%,41.8%)", - "oklch": "oklch(0.51,0.19,28)", - "rgbChannel": "185 28 28", - "hslChannel": "0 73.7% 41.8%" - }, - { - "scale": 800, - "hex": "#991b1b", - "rgb": "rgb(153,27,27)", - "hsl": "hsl(0,70%,35.3%)", - "oklch": "oklch(0.44,0.16,27)", - "rgbChannel": "153 27 27", - "hslChannel": "0 70% 35.3%" - }, - { - "scale": 900, - "hex": "#7f1d1d", - "rgb": "rgb(127,29,29)", - "hsl": "hsl(0,62.8%,30.6%)", - "oklch": "oklch(0.40,0.13,26)", - "rgbChannel": "127 29 29", - "hslChannel": "0 62.8% 30.6%" - }, - { - "scale": 950, - "hex": "#450a0a", - "rgb": "rgb(69,10,10)", - "hsl": "hsl(0,74.7%,15.5%)", - "oklch": "oklch(0.26,0.09,26)", - "rgbChannel": "69 10 10", - "hslChannel": "0 74.7% 15.5%" - } - ], - "orange": [ - { - "scale": 50, - "hex": "#fff7ed", - "rgb": "rgb(255,247,237)", - "hsl": "hsl(33.3,100%,96.5%)", - "oklch": "oklch(0.98,0.02,74)", - "rgbChannel": "255 247 237", - "hslChannel": "33.3 100% 96.5%" - }, - { - "scale": 100, - "hex": "#ffedd5", - "rgb": "rgb(255,237,213)", - "hsl": "hsl(34.3,100%,91.8%)", - "oklch": "oklch(0.95,0.04,75)", - "rgbChannel": "255 237 213", - "hslChannel": "34.3 100% 91.8%" - }, - { - "scale": 200, - "hex": "#fed7aa", - "rgb": "rgb(254,215,170)", - "hsl": "hsl(32.1,97.7%,83.1%)", - "oklch": "oklch(0.90,0.07,71)", - "rgbChannel": "254 215 170", - "hslChannel": "32.1 97.7% 83.1%" - }, - { - "scale": 300, - "hex": "#fdba74", - "rgb": "rgb(253,186,116)", - "hsl": "hsl(30.7,97.2%,72.4%)", - "oklch": "oklch(0.84,0.12,66)", - "rgbChannel": "253 186 116", - "hslChannel": "30.7 97.2% 72.4%" - }, - { - "scale": 400, - "hex": "#fb923c", - "rgb": "rgb(251,146,60)", - "hsl": "hsl(27,96%,61%)", - "oklch": "oklch(0.76,0.16,56)", - "rgbChannel": "251 146 60", - "hslChannel": "27 96% 61%" - }, - { - "scale": 500, - "hex": "#f97316", - "rgb": "rgb(249,115,22)", - "hsl": "hsl(24.6,95%,53.1%)", - "oklch": "oklch(0.70,0.19,48)", - "rgbChannel": "249 115 22", - "hslChannel": "24.6 95% 53.1%" - }, - { - "scale": 600, - "hex": "#ea580c", - "rgb": "rgb(234,88,12)", - "hsl": "hsl(20.5,90.2%,48.2%)", - "oklch": "oklch(0.65,0.19,41)", - "rgbChannel": "234 88 12", - "hslChannel": "20.5 90.2% 48.2%" - }, - { - "scale": 700, - "hex": "#c2410c", - "rgb": "rgb(194,65,12)", - "hsl": "hsl(17.5,88.3%,40.4%)", - "oklch": "oklch(0.55,0.17,38)", - "rgbChannel": "194 65 12", - "hslChannel": "17.5 88.3% 40.4%" - }, - { - "scale": 800, - "hex": "#9a3412", - "rgb": "rgb(154,52,18)", - "hsl": "hsl(15,79.1%,33.7%)", - "oklch": "oklch(0.47,0.14,37)", - "rgbChannel": "154 52 18", - "hslChannel": "15 79.1% 33.7%" - }, - { - "scale": 900, - "hex": "#7c2d12", - "rgb": "rgb(124,45,18)", - "hsl": "hsl(15.3,74.6%,27.8%)", - "oklch": "oklch(0.41,0.12,38)", - "rgbChannel": "124 45 18", - "hslChannel": "15.3 74.6% 27.8%" - }, - { - "scale": 950, - "hex": "#431407", - "rgb": "rgb(67,20,7)", - "hsl": "hsl(13,81.1%,14.5%)", - "oklch": "oklch(0.27,0.08,36)", - "rgbChannel": "67 20 7", - "hslChannel": "13 81.1% 14.5%" - } - ], - "amber": [ - { - "scale": 50, - "hex": "#fffbeb", - "rgb": "rgb(255,251,235)", - "hsl": "hsl(48,100%,96.1%)", - "oklch": "oklch(0.99,0.02,95)", - "rgbChannel": "255 251 235", - "hslChannel": "48 100% 96.1%" - }, - { - "scale": 100, - "hex": "#fef3c7", - "rgb": "rgb(254,243,199)", - "hsl": "hsl(48,96.5%,88.8%)", - "oklch": "oklch(0.96,0.06,96)", - "rgbChannel": "254 243 199", - "hslChannel": "48 96.5% 88.8%" - }, - { - "scale": 200, - "hex": "#fde68a", - "rgb": "rgb(253,230,138)", - "hsl": "hsl(48,96.6%,76.7%)", - "oklch": "oklch(0.92,0.12,96)", - "rgbChannel": "253 230 138", - "hslChannel": "48 96.6% 76.7%" - }, - { - "scale": 300, - "hex": "#fcd34d", - "rgb": "rgb(252,211,77)", - "hsl": "hsl(45.9,96.7%,64.5%)", - "oklch": "oklch(0.88,0.15,92)", - "rgbChannel": "252 211 77", - "hslChannel": "45.9 96.7% 64.5%" - }, - { - "scale": 400, - "hex": "#fbbf24", - "rgb": "rgb(251,191,36)", - "hsl": "hsl(43.3,96.4%,56.3%)", - "oklch": "oklch(0.84,0.16,84)", - "rgbChannel": "251 191 36", - "hslChannel": "43.3 96.4% 56.3%" - }, - { - "scale": 500, - "hex": "#f59e0b", - "rgb": "rgb(245,158,11)", - "hsl": "hsl(37.7,92.1%,50.2%)", - "oklch": "oklch(0.77,0.16,70)", - "rgbChannel": "245 158 11", - "hslChannel": "37.7 92.1% 50.2%" - }, - { - "scale": 600, - "hex": "#d97706", - "rgb": "rgb(217,119,6)", - "hsl": "hsl(32.1,94.6%,43.7%)", - "oklch": "oklch(0.67,0.16,58)", - "rgbChannel": "217 119 6", - "hslChannel": "32.1 94.6% 43.7%" - }, - { - "scale": 700, - "hex": "#b45309", - "rgb": "rgb(180,83,9)", - "hsl": "hsl(26,90.5%,37.1%)", - "oklch": "oklch(0.56,0.15,49)", - "rgbChannel": "180 83 9", - "hslChannel": "26 90.5% 37.1%" - }, - { - "scale": 800, - "hex": "#92400e", - "rgb": "rgb(146,64,14)", - "hsl": "hsl(22.7,82.5%,31.4%)", - "oklch": "oklch(0.47,0.12,46)", - "rgbChannel": "146 64 14", - "hslChannel": "22.7 82.5% 31.4%" - }, - { - "scale": 900, - "hex": "#78350f", - "rgb": "rgb(120,53,15)", - "hsl": "hsl(21.7,77.8%,26.5%)", - "oklch": "oklch(0.41,0.11,46)", - "rgbChannel": "120 53 15", - "hslChannel": "21.7 77.8% 26.5%" - }, - { - "scale": 950, - "hex": "#451a03", - "rgb": "rgb(69,26,3)", - "hsl": "hsl(20.9,91.7%,14.1%)", - "oklch": "oklch(0.28,0.07,46)", - "rgbChannel": "69 26 3", - "hslChannel": "20.9 91.7% 14.1%" - } - ], - "yellow": [ - { - "scale": 50, - "hex": "#fefce8", - "rgb": "rgb(254,252,232)", - "hsl": "hsl(54.5,91.7%,95.3%)", - "oklch": "oklch(0.99,0.03,102)", - "rgbChannel": "254 252 232", - "hslChannel": "54.5 91.7% 95.3%" - }, - { - "scale": 100, - "hex": "#fef9c3", - "rgb": "rgb(254,249,195)", - "hsl": "hsl(54.9,96.7%,88%)", - "oklch": "oklch(0.97,0.07,103)", - "rgbChannel": "254 249 195", - "hslChannel": "54.9 96.7% 88%" - }, - { - "scale": 200, - "hex": "#fef08a", - "rgb": "rgb(254,240,138)", - "hsl": "hsl(52.8,98.3%,76.9%)", - "oklch": "oklch(0.95,0.12,102)", - "rgbChannel": "254 240 138", - "hslChannel": "52.8 98.3% 76.9%" - }, - { - "scale": 300, - "hex": "#fde047", - "rgb": "rgb(253,224,71)", - "hsl": "hsl(50.4,97.8%,63.5%)", - "oklch": "oklch(0.91,0.17,98)", - "rgbChannel": "253 224 71", - "hslChannel": "50.4 97.8% 63.5%" - }, - { - "scale": 400, - "hex": "#facc15", - "rgb": "rgb(250,204,21)", - "hsl": "hsl(47.9,95.8%,53.1%)", - "oklch": "oklch(0.86,0.17,92)", - "rgbChannel": "250 204 21", - "hslChannel": "47.9 95.8% 53.1%" - }, - { - "scale": 500, - "hex": "#eab308", - "rgb": "rgb(234,179,8)", - "hsl": "hsl(45.4,93.4%,47.5%)", - "oklch": "oklch(0.80,0.16,86)", - "rgbChannel": "234 179 8", - "hslChannel": "45.4 93.4% 47.5%" - }, - { - "scale": 600, - "hex": "#ca8a04", - "rgb": "rgb(202,138,4)", - "hsl": "hsl(40.6,96.1%,40.4%)", - "oklch": "oklch(0.68,0.14,76)", - "rgbChannel": "202 138 4", - "hslChannel": "40.6 96.1% 40.4%" - }, - { - "scale": 700, - "hex": "#a16207", - "rgb": "rgb(161,98,7)", - "hsl": "hsl(35.5,91.7%,32.9%)", - "oklch": "oklch(0.55,0.12,66)", - "rgbChannel": "161 98 7", - "hslChannel": "35.5 91.7% 32.9%" - }, - { - "scale": 800, - "hex": "#854d0e", - "rgb": "rgb(133,77,14)", - "hsl": "hsl(31.8,81%,28.8%)", - "oklch": "oklch(0.48,0.10,62)", - "rgbChannel": "133 77 14", - "hslChannel": "31.8 81% 28.8%" - }, - { - "scale": 900, - "hex": "#713f12", - "rgb": "rgb(113,63,18)", - "hsl": "hsl(28.4,72.5%,25.7%)", - "oklch": "oklch(0.42,0.09,58)", - "rgbChannel": "113 63 18", - "hslChannel": "28.4 72.5% 25.7%" - }, - { - "scale": 950, - "hex": "#422006", - "rgb": "rgb(66,32,6)", - "hsl": "hsl(26,83.3%,14.1%)", - "oklch": "oklch(0.29,0.06,54)", - "rgbChannel": "66 32 6", - "hslChannel": "26 83.3% 14.1%" - } - ], - "lime": [ - { - "scale": 50, - "hex": "#f7fee7", - "rgb": "rgb(247,254,231)", - "hsl": "hsl(78.3,92%,95.1%)", - "oklch": "oklch(0.99,0.03,121)", - "rgbChannel": "247 254 231", - "hslChannel": "78.3 92% 95.1%" - }, - { - "scale": 100, - "hex": "#ecfccb", - "rgb": "rgb(236,252,203)", - "hsl": "hsl(79.6,89.1%,89.2%)", - "oklch": "oklch(0.97,0.07,122)", - "rgbChannel": "236 252 203", - "hslChannel": "79.6 89.1% 89.2%" - }, - { - "scale": 200, - "hex": "#d9f99d", - "rgb": "rgb(217,249,157)", - "hsl": "hsl(80.9,88.5%,79.6%)", - "oklch": "oklch(0.94,0.12,124)", - "rgbChannel": "217 249 157", - "hslChannel": "80.9 88.5% 79.6%" - }, - { - "scale": 300, - "hex": "#bef264", - "rgb": "rgb(190,242,100)", - "hsl": "hsl(82,84.5%,67.1%)", - "oklch": "oklch(0.90,0.18,127)", - "rgbChannel": "190 242 100", - "hslChannel": "82 84.5% 67.1%" - }, - { - "scale": 400, - "hex": "#a3e635", - "rgb": "rgb(163,230,53)", - "hsl": "hsl(82.7,78%,55.5%)", - "oklch": "oklch(0.85,0.21,129)", - "rgbChannel": "163 230 53", - "hslChannel": "82.7 78% 55.5%" - }, - { - "scale": 500, - "hex": "#84cc16", - "rgb": "rgb(132,204,22)", - "hsl": "hsl(83.7,80.5%,44.3%)", - "oklch": "oklch(0.77,0.20,131)", - "rgbChannel": "132 204 22", - "hslChannel": "83.7 80.5% 44.3%" - }, - { - "scale": 600, - "hex": "#65a30d", - "rgb": "rgb(101,163,13)", - "hsl": "hsl(84.8,85.2%,34.5%)", - "oklch": "oklch(0.65,0.18,132)", - "rgbChannel": "101 163 13", - "hslChannel": "84.8 85.2% 34.5%" - }, - { - "scale": 700, - "hex": "#4d7c0f", - "rgb": "rgb(77,124,15)", - "hsl": "hsl(85.9,78.4%,27.3%)", - "oklch": "oklch(0.53,0.14,132)", - "rgbChannel": "77 124 15", - "hslChannel": "85.9 78.4% 27.3%" - }, - { - "scale": 800, - "hex": "#3f6212", - "rgb": "rgb(63,98,18)", - "hsl": "hsl(86.3,69%,22.7%)", - "oklch": "oklch(0.45,0.11,131)", - "rgbChannel": "63 98 18", - "hslChannel": "86.3 69% 22.7%" - }, - { - "scale": 900, - "hex": "#365314", - "rgb": "rgb(54,83,20)", - "hsl": "hsl(87.6,61.2%,20.2%)", - "oklch": "oklch(0.41,0.10,131)", - "rgbChannel": "54 83 20", - "hslChannel": "87.6 61.2% 20.2%" - }, - { - "scale": 950, - "hex": "#1a2e05", - "rgb": "rgb(26,46,5)", - "hsl": "hsl(89.3,80.4%,10%)", - "oklch": "oklch(0.27,0.07,132)", - "rgbChannel": "26 46 5", - "hslChannel": "89.3 80.4% 10%" - } - ], - "green": [ - { - "scale": 50, - "hex": "#f0fdf4", - "rgb": "rgb(240,253,244)", - "hsl": "hsl(138.5,76.5%,96.7%)", - "oklch": "oklch(0.98,0.02,156)", - "rgbChannel": "240 253 244", - "hslChannel": "138.5 76.5% 96.7%" - }, - { - "scale": 100, - "hex": "#dcfce7", - "rgb": "rgb(220,252,231)", - "hsl": "hsl(140.6,84.2%,92.5%)", - "oklch": "oklch(0.96,0.04,157)", - "rgbChannel": "220 252 231", - "hslChannel": "140.6 84.2% 92.5%" - }, - { - "scale": 200, - "hex": "#bbf7d0", - "rgb": "rgb(187,247,208)", - "hsl": "hsl(141,78.9%,85.1%)", - "oklch": "oklch(0.93,0.08,156)", - "rgbChannel": "187 247 208", - "hslChannel": "141 78.9% 85.1%" - }, - { - "scale": 300, - "hex": "#86efac", - "rgb": "rgb(134,239,172)", - "hsl": "hsl(141.7,76.6%,73.1%)", - "oklch": "oklch(0.87,0.14,154)", - "rgbChannel": "134 239 172", - "hslChannel": "141.7 76.6% 73.1%" - }, - { - "scale": 400, - "hex": "#4ade80", - "rgb": "rgb(74,222,128)", - "hsl": "hsl(141.9,69.2%,58%)", - "oklch": "oklch(0.80,0.18,152)", - "rgbChannel": "74 222 128", - "hslChannel": "141.9 69.2% 58%" - }, - { - "scale": 500, - "hex": "#22c55e", - "rgb": "rgb(34,197,94)", - "hsl": "hsl(142.1,70.6%,45.3%)", - "oklch": "oklch(0.72,0.19,150)", - "rgbChannel": "34 197 94", - "hslChannel": "142.1 70.6% 45.3%" - }, - { - "scale": 600, - "hex": "#16a34a", - "rgb": "rgb(22,163,74)", - "hsl": "hsl(142.1,76.2%,36.3%)", - "oklch": "oklch(0.63,0.17,149)", - "rgbChannel": "22 163 74", - "hslChannel": "142.1 76.2% 36.3%" - }, - { - "scale": 700, - "hex": "#15803d", - "rgb": "rgb(21,128,61)", - "hsl": "hsl(142.4,71.8%,29.2%)", - "oklch": "oklch(0.53,0.14,150)", - "rgbChannel": "21 128 61", - "hslChannel": "142.4 71.8% 29.2%" - }, - { - "scale": 800, - "hex": "#166534", - "rgb": "rgb(22,101,52)", - "hsl": "hsl(142.8,64.2%,24.1%)", - "oklch": "oklch(0.45,0.11,151)", - "rgbChannel": "22 101 52", - "hslChannel": "142.8 64.2% 24.1%" - }, - { - "scale": 900, - "hex": "#14532d", - "rgb": "rgb(20,83,45)", - "hsl": "hsl(143.8,61.2%,20.2%)", - "oklch": "oklch(0.39,0.09,153)", - "rgbChannel": "20 83 45", - "hslChannel": "143.8 61.2% 20.2%" - }, - { - "scale": 950, - "hex": "#052e16", - "rgb": "rgb(5,46,22)", - "hsl": "hsl(144.9,80.4%,10%)", - "oklch": "oklch(0.27,0.06,153)", - "rgbChannel": "5 46 22", - "hslChannel": "144.9 80.4% 10%" - } - ], - "emerald": [ - { - "scale": 50, - "hex": "#ecfdf5", - "rgb": "rgb(236,253,245)", - "hsl": "hsl(151.8,81%,95.9%)", - "oklch": "oklch(0.98,0.02,166)", - "rgbChannel": "236 253 245", - "hslChannel": "151.8 81% 95.9%" - }, - { - "scale": 100, - "hex": "#d1fae5", - "rgb": "rgb(209,250,229)", - "hsl": "hsl(149.3,80.4%,90%)", - "oklch": "oklch(0.95,0.05,163)", - "rgbChannel": "209 250 229", - "hslChannel": "149.3 80.4% 90%" - }, - { - "scale": 200, - "hex": "#a7f3d0", - "rgb": "rgb(167,243,208)", - "hsl": "hsl(152.4,76%,80.4%)", - "oklch": "oklch(0.90,0.09,164)", - "rgbChannel": "167 243 208", - "hslChannel": "152.4 76% 80.4%" - }, - { - "scale": 300, - "hex": "#6ee7b7", - "rgb": "rgb(110,231,183)", - "hsl": "hsl(156.2,71.6%,66.9%)", - "oklch": "oklch(0.85,0.13,165)", - "rgbChannel": "110 231 183", - "hslChannel": "156.2 71.6% 66.9%" - }, - { - "scale": 400, - "hex": "#34d399", - "rgb": "rgb(52,211,153)", - "hsl": "hsl(158.1,64.4%,51.6%)", - "oklch": "oklch(0.77,0.15,163)", - "rgbChannel": "52 211 153", - "hslChannel": "158.1 64.4% 51.6%" - }, - { - "scale": 500, - "hex": "#10b981", - "rgb": "rgb(16,185,129)", - "hsl": "hsl(160.1,84.1%,39.4%)", - "oklch": "oklch(0.70,0.15,162)", - "rgbChannel": "16 185 129", - "hslChannel": "160.1 84.1% 39.4%" - }, - { - "scale": 600, - "hex": "#059669", - "rgb": "rgb(5,150,105)", - "hsl": "hsl(161.4,93.5%,30.4%)", - "oklch": "oklch(0.60,0.13,163)", - "rgbChannel": "5 150 105", - "hslChannel": "161.4 93.5% 30.4%" - }, - { - "scale": 700, - "hex": "#047857", - "rgb": "rgb(4,120,87)", - "hsl": "hsl(162.9,93.5%,24.3%)", - "oklch": "oklch(0.51,0.10,166)", - "rgbChannel": "4 120 87", - "hslChannel": "162.9 93.5% 24.3%" - }, - { - "scale": 800, - "hex": "#065f46", - "rgb": "rgb(6,95,70)", - "hsl": "hsl(163.1,88.1%,19.8%)", - "oklch": "oklch(0.43,0.09,167)", - "rgbChannel": "6 95 70", - "hslChannel": "163.1 88.1% 19.8%" - }, - { - "scale": 900, - "hex": "#064e3b", - "rgb": "rgb(6,78,59)", - "hsl": "hsl(164.2,85.7%,16.5%)", - "oklch": "oklch(0.38,0.07,169)", - "rgbChannel": "6 78 59", - "hslChannel": "164.2 85.7% 16.5%" - }, - { - "scale": 950, - "hex": "#022c22", - "rgb": "rgb(2,44,34)", - "hsl": "hsl(165.7,91.3%,9%)", - "oklch": "oklch(0.26,0.05,173)", - "rgbChannel": "2 44 34", - "hslChannel": "165.7 91.3% 9%" - } - ], - "teal": [ - { - "scale": 50, - "hex": "#f0fdfa", - "rgb": "rgb(240,253,250)", - "hsl": "hsl(166.2,76.5%,96.7%)", - "oklch": "oklch(0.98,0.01,181)", - "rgbChannel": "240 253 250", - "hslChannel": "166.2 76.5% 96.7%" - }, - { - "scale": 100, - "hex": "#ccfbf1", - "rgb": "rgb(204,251,241)", - "hsl": "hsl(167.2,85.5%,89.2%)", - "oklch": "oklch(0.95,0.05,181)", - "rgbChannel": "204 251 241", - "hslChannel": "167.2 85.5% 89.2%" - }, - { - "scale": 200, - "hex": "#99f6e4", - "rgb": "rgb(153,246,228)", - "hsl": "hsl(168.4,83.8%,78.2%)", - "oklch": "oklch(0.91,0.09,180)", - "rgbChannel": "153 246 228", - "hslChannel": "168.4 83.8% 78.2%" - }, - { - "scale": 300, - "hex": "#5eead4", - "rgb": "rgb(94,234,212)", - "hsl": "hsl(170.6,76.9%,64.3%)", - "oklch": "oklch(0.85,0.13,181)", - "rgbChannel": "94 234 212", - "hslChannel": "170.6 76.9% 64.3%" - }, - { - "scale": 400, - "hex": "#2dd4bf", - "rgb": "rgb(45,212,191)", - "hsl": "hsl(172.5,66%,50.4%)", - "oklch": "oklch(0.78,0.13,182)", - "rgbChannel": "45 212 191", - "hslChannel": "172.5 66% 50.4%" - }, - { - "scale": 500, - "hex": "#14b8a6", - "rgb": "rgb(20,184,166)", - "hsl": "hsl(173.4,80.4%,40%)", - "oklch": "oklch(0.70,0.12,183)", - "rgbChannel": "20 184 166", - "hslChannel": "173.4 80.4% 40%" - }, - { - "scale": 600, - "hex": "#0d9488", - "rgb": "rgb(13,148,136)", - "hsl": "hsl(174.7,83.9%,31.6%)", - "oklch": "oklch(0.60,0.10,185)", - "rgbChannel": "13 148 136", - "hslChannel": "174.7 83.9% 31.6%" - }, - { - "scale": 700, - "hex": "#0f766e", - "rgb": "rgb(15,118,110)", - "hsl": "hsl(175.3,77.4%,26.1%)", - "oklch": "oklch(0.51,0.09,186)", - "rgbChannel": "15 118 110", - "hslChannel": "175.3 77.4% 26.1%" - }, - { - "scale": 800, - "hex": "#115e59", - "rgb": "rgb(17,94,89)", - "hsl": "hsl(176.1,69.4%,21.8%)", - "oklch": "oklch(0.44,0.07,188)", - "rgbChannel": "17 94 89", - "hslChannel": "176.1 69.4% 21.8%" - }, - { - "scale": 900, - "hex": "#134e4a", - "rgb": "rgb(19,78,74)", - "hsl": "hsl(175.9,60.8%,19%)", - "oklch": "oklch(0.39,0.06,188)", - "rgbChannel": "19 78 74", - "hslChannel": "175.9 60.8% 19%" - }, - { - "scale": 950, - "hex": "#042f2e", - "rgb": "rgb(4,47,46)", - "hsl": "hsl(178.6,84.3%,10%)", - "oklch": "oklch(0.28,0.04,193)", - "rgbChannel": "4 47 46", - "hslChannel": "178.6 84.3% 10%" - } - ], - "cyan": [ - { - "scale": 50, - "hex": "#ecfeff", - "rgb": "rgb(236,254,255)", - "hsl": "hsl(183.2,100%,96.3%)", - "oklch": "oklch(0.98,0.02,201)", - "rgbChannel": "236 254 255", - "hslChannel": "183.2 100% 96.3%" - }, - { - "scale": 100, - "hex": "#cffafe", - "rgb": "rgb(207,250,254)", - "hsl": "hsl(185.1,95.9%,90.4%)", - "oklch": "oklch(0.96,0.04,203)", - "rgbChannel": "207 250 254", - "hslChannel": "185.1 95.9% 90.4%" - }, - { - "scale": 200, - "hex": "#a5f3fc", - "rgb": "rgb(165,243,252)", - "hsl": "hsl(186.2,93.5%,81.8%)", - "oklch": "oklch(0.92,0.08,205)", - "rgbChannel": "165 243 252", - "hslChannel": "186.2 93.5% 81.8%" - }, - { - "scale": 300, - "hex": "#67e8f9", - "rgb": "rgb(103,232,249)", - "hsl": "hsl(187,92.4%,69%)", - "oklch": "oklch(0.87,0.12,207)", - "rgbChannel": "103 232 249", - "hslChannel": "187 92.4% 69%" - }, - { - "scale": 400, - "hex": "#22d3ee", - "rgb": "rgb(34,211,238)", - "hsl": "hsl(187.9,85.7%,53.3%)", - "oklch": "oklch(0.80,0.13,212)", - "rgbChannel": "34 211 238", - "hslChannel": "187.9 85.7% 53.3%" - }, - { - "scale": 500, - "hex": "#06b6d4", - "rgb": "rgb(6,182,212)", - "hsl": "hsl(188.7,94.5%,42.7%)", - "oklch": "oklch(0.71,0.13,215)", - "rgbChannel": "6 182 212", - "hslChannel": "188.7 94.5% 42.7%" - }, - { - "scale": 600, - "hex": "#0891b2", - "rgb": "rgb(8,145,178)", - "hsl": "hsl(191.6,91.4%,36.5%)", - "oklch": "oklch(0.61,0.11,222)", - "rgbChannel": "8 145 178", - "hslChannel": "191.6 91.4% 36.5%" - }, - { - "scale": 700, - "hex": "#0e7490", - "rgb": "rgb(14,116,144)", - "hsl": "hsl(192.9,82.3%,31%)", - "oklch": "oklch(0.52,0.09,223)", - "rgbChannel": "14 116 144", - "hslChannel": "192.9 82.3% 31%" - }, - { - "scale": 800, - "hex": "#155e75", - "rgb": "rgb(21,94,117)", - "hsl": "hsl(194.4,69.6%,27.1%)", - "oklch": "oklch(0.45,0.08,224)", - "rgbChannel": "21 94 117", - "hslChannel": "194.4 69.6% 27.1%" - }, - { - "scale": 900, - "hex": "#164e63", - "rgb": "rgb(22,78,99)", - "hsl": "hsl(196.4,63.6%,23.7%)", - "oklch": "oklch(0.40,0.07,227)", - "rgbChannel": "22 78 99", - "hslChannel": "196.4 63.6% 23.7%" - }, - { - "scale": 950, - "hex": "#083344", - "rgb": "rgb(8,51,68)", - "hsl": "hsl(197,78.9%,14.9%)", - "oklch": "oklch(0.30,0.05,230)", - "rgbChannel": "8 51 68", - "hslChannel": "197 78.9% 14.9%" - } - ], - "sky": [ - { - "scale": 50, - "hex": "#f0f9ff", - "rgb": "rgb(240,249,255)", - "hsl": "hsl(204,100%,97.1%)", - "oklch": "oklch(0.98,0.01,237)", - "rgbChannel": "240 249 255", - "hslChannel": "204 100% 97.1%" - }, - { - "scale": 100, - "hex": "#e0f2fe", - "rgb": "rgb(224,242,254)", - "hsl": "hsl(204,93.8%,93.7%)", - "oklch": "oklch(0.95,0.03,237)", - "rgbChannel": "224 242 254", - "hslChannel": "204 93.8% 93.7%" - }, - { - "scale": 200, - "hex": "#bae6fd", - "rgb": "rgb(186,230,253)", - "hsl": "hsl(200.6,94.4%,86.1%)", - "oklch": "oklch(0.90,0.06,231)", - "rgbChannel": "186 230 253", - "hslChannel": "200.6 94.4% 86.1%" - }, - { - "scale": 300, - "hex": "#7dd3fc", - "rgb": "rgb(125,211,252)", - "hsl": "hsl(199.4,95.5%,73.9%)", - "oklch": "oklch(0.83,0.10,230)", - "rgbChannel": "125 211 252", - "hslChannel": "199.4 95.5% 73.9%" - }, - { - "scale": 400, - "hex": "#38bdf8", - "rgb": "rgb(56,189,248)", - "hsl": "hsl(198.4,93.2%,59.6%)", - "oklch": "oklch(0.75,0.14,233)", - "rgbChannel": "56 189 248", - "hslChannel": "198.4 93.2% 59.6%" - }, - { - "scale": 500, - "hex": "#0ea5e9", - "rgb": "rgb(14,165,233)", - "hsl": "hsl(198.6,88.7%,48.4%)", - "oklch": "oklch(0.68,0.15,237)", - "rgbChannel": "14 165 233", - "hslChannel": "198.6 88.7% 48.4%" - }, - { - "scale": 600, - "hex": "#0284c7", - "rgb": "rgb(2,132,199)", - "hsl": "hsl(200.4,98%,39.4%)", - "oklch": "oklch(0.59,0.14,242)", - "rgbChannel": "2 132 199", - "hslChannel": "200.4 98% 39.4%" - }, - { - "scale": 700, - "hex": "#0369a1", - "rgb": "rgb(3,105,161)", - "hsl": "hsl(201.3,96.3%,32.2%)", - "oklch": "oklch(0.50,0.12,243)", - "rgbChannel": "3 105 161", - "hslChannel": "201.3 96.3% 32.2%" - }, - { - "scale": 800, - "hex": "#075985", - "rgb": "rgb(7,89,133)", - "hsl": "hsl(201,90%,27.5%)", - "oklch": "oklch(0.44,0.10,241)", - "rgbChannel": "7 89 133", - "hslChannel": "201 90% 27.5%" - }, - { - "scale": 900, - "hex": "#0c4a6e", - "rgb": "rgb(12,74,110)", - "hsl": "hsl(202,80.3%,23.9%)", - "oklch": "oklch(0.39,0.08,241)", - "rgbChannel": "12 74 110", - "hslChannel": "202 80.3% 23.9%" - }, - { - "scale": 950, - "hex": "#082f49", - "rgb": "rgb(8,47,73)", - "hsl": "hsl(204,80.2%,15.9%)", - "oklch": "oklch(0.29,0.06,243)", - "rgbChannel": "8 47 73", - "hslChannel": "204 80.2% 15.9%" - } - ], - "blue": [ - { - "scale": 50, - "hex": "#eff6ff", - "rgb": "rgb(239,246,255)", - "hsl": "hsl(213.8,100%,96.9%)", - "oklch": "oklch(0.97,0.01,255)", - "rgbChannel": "239 246 255", - "hslChannel": "213.8 100% 96.9%" - }, - { - "scale": 100, - "hex": "#dbeafe", - "rgb": "rgb(219,234,254)", - "hsl": "hsl(214.3,94.6%,92.7%)", - "oklch": "oklch(0.93,0.03,256)", - "rgbChannel": "219 234 254", - "hslChannel": "214.3 94.6% 92.7%" - }, - { - "scale": 200, - "hex": "#bfdbfe", - "rgb": "rgb(191,219,254)", - "hsl": "hsl(213.3,96.9%,87.3%)", - "oklch": "oklch(0.88,0.06,254)", - "rgbChannel": "191 219 254", - "hslChannel": "213.3 96.9% 87.3%" - }, - { - "scale": 300, - "hex": "#93c5fd", - "rgb": "rgb(147,197,253)", - "hsl": "hsl(211.7,96.4%,78.4%)", - "oklch": "oklch(0.81,0.10,252)", - "rgbChannel": "147 197 253", - "hslChannel": "211.7 96.4% 78.4%" - }, - { - "scale": 400, - "hex": "#60a5fa", - "rgb": "rgb(96,165,250)", - "hsl": "hsl(213.1,93.9%,67.8%)", - "oklch": "oklch(0.71,0.14,255)", - "rgbChannel": "96 165 250", - "hslChannel": "213.1 93.9% 67.8%" - }, - { - "scale": 500, - "hex": "#3b82f6", - "rgb": "rgb(59,130,246)", - "hsl": "hsl(217.2,91.2%,59.8%)", - "oklch": "oklch(0.62,0.19,260)", - "rgbChannel": "59 130 246", - "hslChannel": "217.2 91.2% 59.8%" - }, - { - "scale": 600, - "hex": "#2563eb", - "rgb": "rgb(37,99,235)", - "hsl": "hsl(221.2,83.2%,53.3%)", - "oklch": "oklch(0.55,0.22,263)", - "rgbChannel": "37 99 235", - "hslChannel": "221.2 83.2% 53.3%" - }, - { - "scale": 700, - "hex": "#1d4ed8", - "rgb": "rgb(29,78,216)", - "hsl": "hsl(224.3,76.3%,48%)", - "oklch": "oklch(0.49,0.22,264)", - "rgbChannel": "29 78 216", - "hslChannel": "224.3 76.3% 48%" - }, - { - "scale": 800, - "hex": "#1e40af", - "rgb": "rgb(30,64,175)", - "hsl": "hsl(225.9,70.7%,40.2%)", - "oklch": "oklch(0.42,0.18,266)", - "rgbChannel": "30 64 175", - "hslChannel": "225.9 70.7% 40.2%" - }, - { - "scale": 900, - "hex": "#1e3a8a", - "rgb": "rgb(30,58,138)", - "hsl": "hsl(224.4,64.3%,32.9%)", - "oklch": "oklch(0.38,0.14,266)", - "rgbChannel": "30 58 138", - "hslChannel": "224.4 64.3% 32.9%" - }, - { - "scale": 950, - "hex": "#172554", - "rgb": "rgb(23,37,84)", - "hsl": "hsl(226.2,57%,21%)", - "oklch": "oklch(0.28,0.09,268)", - "rgbChannel": "23 37 84", - "hslChannel": "226.2 57% 21%" - } - ], - "indigo": [ - { - "scale": 50, - "hex": "#eef2ff", - "rgb": "rgb(238,242,255)", - "hsl": "hsl(225.9,100%,96.7%)", - "oklch": "oklch(0.96,0.02,272)", - "rgbChannel": "238 242 255", - "hslChannel": "225.9 100% 96.7%" - }, - { - "scale": 100, - "hex": "#e0e7ff", - "rgb": "rgb(224,231,255)", - "hsl": "hsl(226.5,100%,93.9%)", - "oklch": "oklch(0.93,0.03,273)", - "rgbChannel": "224 231 255", - "hslChannel": "226.5 100% 93.9%" - }, - { - "scale": 200, - "hex": "#c7d2fe", - "rgb": "rgb(199,210,254)", - "hsl": "hsl(228,96.5%,88.8%)", - "oklch": "oklch(0.87,0.06,274)", - "rgbChannel": "199 210 254", - "hslChannel": "228 96.5% 88.8%" - }, - { - "scale": 300, - "hex": "#a5b4fc", - "rgb": "rgb(165,180,252)", - "hsl": "hsl(229.7,93.5%,81.8%)", - "oklch": "oklch(0.79,0.10,275)", - "rgbChannel": "165 180 252", - "hslChannel": "229.7 93.5% 81.8%" - }, - { - "scale": 400, - "hex": "#818cf8", - "rgb": "rgb(129,140,248)", - "hsl": "hsl(234.5,89.5%,73.9%)", - "oklch": "oklch(0.68,0.16,277)", - "rgbChannel": "129 140 248", - "hslChannel": "234.5 89.5% 73.9%" - }, - { - "scale": 500, - "hex": "#6366f1", - "rgb": "rgb(99,102,241)", - "hsl": "hsl(238.7,83.5%,66.7%)", - "oklch": "oklch(0.59,0.20,277)", - "rgbChannel": "99 102 241", - "hslChannel": "238.7 83.5% 66.7%" - }, - { - "scale": 600, - "hex": "#4f46e5", - "rgb": "rgb(79,70,229)", - "hsl": "hsl(243.4,75.4%,58.6%)", - "oklch": "oklch(0.51,0.23,277)", - "rgbChannel": "79 70 229", - "hslChannel": "243.4 75.4% 58.6%" - }, - { - "scale": 700, - "hex": "#4338ca", - "rgb": "rgb(67,56,202)", - "hsl": "hsl(244.5,57.9%,50.6%)", - "oklch": "oklch(0.46,0.21,277)", - "rgbChannel": "67 56 202", - "hslChannel": "244.5 57.9% 50.6%" - }, - { - "scale": 800, - "hex": "#3730a3", - "rgb": "rgb(55,48,163)", - "hsl": "hsl(243.7,54.5%,41.4%)", - "oklch": "oklch(0.40,0.18,277)", - "rgbChannel": "55 48 163", - "hslChannel": "243.7 54.5% 41.4%" - }, - { - "scale": 900, - "hex": "#312e81", - "rgb": "rgb(49,46,129)", - "hsl": "hsl(242.2,47.4%,34.3%)", - "oklch": "oklch(0.36,0.14,279)", - "rgbChannel": "49 46 129", - "hslChannel": "242.2 47.4% 34.3%" - }, - { - "scale": 950, - "hex": "#1e1b4b", - "rgb": "rgb(30,27,75)", - "hsl": "hsl(243.8,47.1%,20%)", - "oklch": "oklch(0.26,0.09,281)", - "rgbChannel": "30 27 75", - "hslChannel": "243.8 47.1% 20%" - } - ], - "violet": [ - { - "scale": 50, - "hex": "#f5f3ff", - "rgb": "rgb(245,243,255)", - "hsl": "hsl(250,100%,97.6%)", - "oklch": "oklch(0.97,0.02,294)", - "rgbChannel": "245 243 255", - "hslChannel": "250 100% 97.6%" - }, - { - "scale": 100, - "hex": "#ede9fe", - "rgb": "rgb(237,233,254)", - "hsl": "hsl(251.4,91.3%,95.5%)", - "oklch": "oklch(0.94,0.03,295)", - "rgbChannel": "237 233 254", - "hslChannel": "251.4 91.3% 95.5%" - }, - { - "scale": 200, - "hex": "#ddd6fe", - "rgb": "rgb(221,214,254)", - "hsl": "hsl(250.5,95.2%,91.8%)", - "oklch": "oklch(0.89,0.05,293)", - "rgbChannel": "221 214 254", - "hslChannel": "250.5 95.2% 91.8%" - }, - { - "scale": 300, - "hex": "#c4b5fd", - "rgb": "rgb(196,181,253)", - "hsl": "hsl(252.5,94.7%,85.1%)", - "oklch": "oklch(0.81,0.10,294)", - "rgbChannel": "196 181 253", - "hslChannel": "252.5 94.7% 85.1%" - }, - { - "scale": 400, - "hex": "#a78bfa", - "rgb": "rgb(167,139,250)", - "hsl": "hsl(255.1,91.7%,76.3%)", - "oklch": "oklch(0.71,0.16,294)", - "rgbChannel": "167 139 250", - "hslChannel": "255.1 91.7% 76.3%" - }, - { - "scale": 500, - "hex": "#8b5cf6", - "rgb": "rgb(139,92,246)", - "hsl": "hsl(258.3,89.5%,66.3%)", - "oklch": "oklch(0.61,0.22,293)", - "rgbChannel": "139 92 246", - "hslChannel": "258.3 89.5% 66.3%" - }, - { - "scale": 600, - "hex": "#7c3aed", - "rgb": "rgb(124,58,237)", - "hsl": "hsl(262.1,83.3%,57.8%)", - "oklch": "oklch(0.54,0.25,293)", - "rgbChannel": "124 58 237", - "hslChannel": "262.1 83.3% 57.8%" - }, - { - "scale": 700, - "hex": "#6d28d9", - "rgb": "rgb(109,40,217)", - "hsl": "hsl(263.4,70%,50.4%)", - "oklch": "oklch(0.49,0.24,293)", - "rgbChannel": "109 40 217", - "hslChannel": "263.4 70% 50.4%" - }, - { - "scale": 800, - "hex": "#5b21b6", - "rgb": "rgb(91,33,182)", - "hsl": "hsl(263.4,69.3%,42.2%)", - "oklch": "oklch(0.43,0.21,293)", - "rgbChannel": "91 33 182", - "hslChannel": "263.4 69.3% 42.2%" - }, - { - "scale": 900, - "hex": "#4c1d95", - "rgb": "rgb(76,29,149)", - "hsl": "hsl(263.5,67.4%,34.9%)", - "oklch": "oklch(0.38,0.18,294)", - "rgbChannel": "76 29 149", - "hslChannel": "263.5 67.4% 34.9%" - }, - { - "scale": 950, - "hex": "#1e1b4b", - "rgb": "rgb(46,16,101)", - "hsl": "hsl(261.2,72.6%,22.9%)", - "oklch": "oklch(0.28,0.14,291)", - "rgbChannel": "46 16 101", - "hslChannel": "261.2 72.6% 22.9%" - } - ], - "purple": [ - { - "scale": 50, - "hex": "#faf5ff", - "rgb": "rgb(250,245,255)", - "hsl": "hsl(270,100%,98%)", - "oklch": "oklch(0.98,0.01,308)", - "rgbChannel": "250 245 255", - "hslChannel": "270 100% 98%" - }, - { - "scale": 100, - "hex": "#f3e8ff", - "rgb": "rgb(243,232,255)", - "hsl": "hsl(268.7,100%,95.5%)", - "oklch": "oklch(0.95,0.03,307)", - "rgbChannel": "243 232 255", - "hslChannel": "268.7 100% 95.5%" - }, - { - "scale": 200, - "hex": "#e9d5ff", - "rgb": "rgb(233,213,255)", - "hsl": "hsl(268.6,100%,91.8%)", - "oklch": "oklch(0.90,0.06,307)", - "rgbChannel": "233 213 255", - "hslChannel": "268.6 100% 91.8%" - }, - { - "scale": 300, - "hex": "#d8b4fe", - "rgb": "rgb(216,180,254)", - "hsl": "hsl(269.2,97.4%,85.1%)", - "oklch": "oklch(0.83,0.11,306)", - "rgbChannel": "216 180 254", - "hslChannel": "269.2 97.4% 85.1%" - }, - { - "scale": 400, - "hex": "#c084fc", - "rgb": "rgb(192,132,252)", - "hsl": "hsl(270,95.2%,75.3%)", - "oklch": "oklch(0.72,0.18,306)", - "rgbChannel": "192 132 252", - "hslChannel": "270 95.2% 75.3%" - }, - { - "scale": 500, - "hex": "#a855f7", - "rgb": "rgb(168,85,247)", - "hsl": "hsl(270.7,91%,65.1%)", - "oklch": "oklch(0.63,0.23,304)", - "rgbChannel": "168 85 247", - "hslChannel": "270.7 91% 65.1%" - }, - { - "scale": 600, - "hex": "#9333ea", - "rgb": "rgb(147,51,234)", - "hsl": "hsl(271.5,81.3%,55.9%)", - "oklch": "oklch(0.56,0.25,302)", - "rgbChannel": "147 51 234", - "hslChannel": "271.5 81.3% 55.9%" - }, - { - "scale": 700, - "hex": "#7e22ce", - "rgb": "rgb(126,34,206)", - "hsl": "hsl(272.1,71.7%,47.1%)", - "oklch": "oklch(0.50,0.24,302)", - "rgbChannel": "126 34 206", - "hslChannel": "272.1 71.7% 47.1%" - }, - { - "scale": 800, - "hex": "#6b21a8", - "rgb": "rgb(107,33,168)", - "hsl": "hsl(272.9,67.2%,39.4%)", - "oklch": "oklch(0.44,0.20,304)", - "rgbChannel": "107 33 168", - "hslChannel": "272.9 67.2% 39.4%" - }, - { - "scale": 900, - "hex": "#581c87", - "rgb": "rgb(88,28,135)", - "hsl": "hsl(273.6,65.6%,32%)", - "oklch": "oklch(0.38,0.17,305)", - "rgbChannel": "88 28 135", - "hslChannel": "273.6 65.6% 32%" - }, - { - "scale": 950, - "hex": "#3b0764", - "rgb": "rgb(59,7,100)", - "hsl": "hsl(273.5,86.9%,21%)", - "oklch": "oklch(0.29,0.14,303)", - "rgbChannel": "59 7 100", - "hslChannel": "273.5 86.9% 21%" - } - ], - "fuchsia": [ - { - "scale": 50, - "hex": "#fdf4ff", - "rgb": "rgb(253,244,255)", - "hsl": "hsl(289.1,100%,97.8%)", - "oklch": "oklch(0.98,0.02,320)", - "rgbChannel": "253 244 255", - "hslChannel": "289.1 100% 97.8%" - }, - { - "scale": 100, - "hex": "#fae8ff", - "rgb": "rgb(250,232,255)", - "hsl": "hsl(287,100%,95.5%)", - "oklch": "oklch(0.95,0.04,319)", - "rgbChannel": "250 232 255", - "hslChannel": "287 100% 95.5%" - }, - { - "scale": 200, - "hex": "#f5d0fe", - "rgb": "rgb(245,208,254)", - "hsl": "hsl(288.3,95.8%,90.6%)", - "oklch": "oklch(0.90,0.07,320)", - "rgbChannel": "245 208 254", - "hslChannel": "288.3 95.8% 90.6%" - }, - { - "scale": 300, - "hex": "#f0abfc", - "rgb": "rgb(240,171,252)", - "hsl": "hsl(291.1,93.1%,82.9%)", - "oklch": "oklch(0.83,0.13,321)", - "rgbChannel": "240 171 252", - "hslChannel": "291.1 93.1% 82.9%" - }, - { - "scale": 400, - "hex": "#e879f9", - "rgb": "rgb(232,121,249)", - "hsl": "hsl(292,91.4%,72.5%)", - "oklch": "oklch(0.75,0.21,322)", - "rgbChannel": "232 121 249", - "hslChannel": "292 91.4% 72.5%" - }, - { - "scale": 500, - "hex": "#d946ef", - "rgb": "rgb(217,70,239)", - "hsl": "hsl(292.2,84.1%,60.6%)", - "oklch": "oklch(0.67,0.26,322)", - "rgbChannel": "217 70 239", - "hslChannel": "292.2 84.1% 60.6%" - }, - { - "scale": 600, - "hex": "#c026d3", - "rgb": "rgb(192,38,211)", - "hsl": "hsl(293.4,69.5%,48.8%)", - "oklch": "oklch(0.59,0.26,323)", - "rgbChannel": "192 38 211", - "hslChannel": "293.4 69.5% 48.8%" - }, - { - "scale": 700, - "hex": "#a21caf", - "rgb": "rgb(162,28,175)", - "hsl": "hsl(294.7,72.4%,39.8%)", - "oklch": "oklch(0.52,0.23,324)", - "rgbChannel": "162 28 175", - "hslChannel": "294.7 72.4% 39.8%" - }, - { - "scale": 800, - "hex": "#86198f", - "rgb": "rgb(134,25,143)", - "hsl": "hsl(295.4,70.2%,32.9%)", - "oklch": "oklch(0.45,0.19,325)", - "rgbChannel": "134 25 143", - "hslChannel": "295.4 70.2% 32.9%" - }, - { - "scale": 900, - "hex": "#701a75", - "rgb": "rgb(112,26,117)", - "hsl": "hsl(296.7,63.6%,28%)", - "oklch": "oklch(0.40,0.16,326)", - "rgbChannel": "112 26 117", - "hslChannel": "296.7 63.6% 28%" - }, - { - "scale": 950, - "hex": "#4a044e", - "rgb": "rgb(74,4,78)", - "hsl": "hsl(296.8,90.2%,16.1%)", - "oklch": "oklch(0.29,0.13,326)", - "rgbChannel": "74 4 78", - "hslChannel": "296.8 90.2% 16.1%" - } - ], - "pink": [ - { - "scale": 50, - "hex": "#fdf2f8", - "rgb": "rgb(253,242,248)", - "hsl": "hsl(327.3,73.3%,97.1%)", - "oklch": "oklch(0.97,0.01,343)", - "rgbChannel": "253 242 248", - "hslChannel": "327.3 73.3% 97.1%" - }, - { - "scale": 100, - "hex": "#fce7f3", - "rgb": "rgb(252,231,243)", - "hsl": "hsl(325.7,77.8%,94.7%)", - "oklch": "oklch(0.95,0.03,342)", - "rgbChannel": "252 231 243", - "hslChannel": "325.7 77.8% 94.7%" - }, - { - "scale": 200, - "hex": "#fbcfe8", - "rgb": "rgb(251,207,232)", - "hsl": "hsl(325.9,84.6%,89.8%)", - "oklch": "oklch(0.90,0.06,343)", - "rgbChannel": "251 207 232", - "hslChannel": "325.9 84.6% 89.8%" - }, - { - "scale": 300, - "hex": "#f9a8d4", - "rgb": "rgb(249,168,212)", - "hsl": "hsl(327.4,87.1%,81.8%)", - "oklch": "oklch(0.82,0.11,346)", - "rgbChannel": "249 168 212", - "hslChannel": "327.4 87.1% 81.8%" - }, - { - "scale": 400, - "hex": "#f472b6", - "rgb": "rgb(244,114,182)", - "hsl": "hsl(328.6,85.5%,70.2%)", - "oklch": "oklch(0.73,0.18,350)", - "rgbChannel": "244 114 182", - "hslChannel": "328.6 85.5% 70.2%" - }, - { - "scale": 500, - "hex": "#ec4899", - "rgb": "rgb(236,72,153)", - "hsl": "hsl(330.4,81.2%,60.4%)", - "oklch": "oklch(0.66,0.21,354)", - "rgbChannel": "236 72 153", - "hslChannel": "330.4 81.2% 60.4%" - }, - { - "scale": 600, - "hex": "#db2777", - "rgb": "rgb(219,39,119)", - "hsl": "hsl(333.3,71.4%,50.6%)", - "oklch": "oklch(0.59,0.22,1)", - "rgbChannel": "219 39 119", - "hslChannel": "333.3 71.4% 50.6%" - }, - { - "scale": 700, - "hex": "#be185d", - "rgb": "rgb(190,24,93)", - "hsl": "hsl(335.1,77.6%,42%)", - "oklch": "oklch(0.52,0.20,4)", - "rgbChannel": "190 24 93", - "hslChannel": "335.1 77.6% 42%" - }, - { - "scale": 800, - "hex": "#9d174d", - "rgb": "rgb(157,23,77)", - "hsl": "hsl(335.8,74.4%,35.3%)", - "oklch": "oklch(0.46,0.17,4)", - "rgbChannel": "157 23 77", - "hslChannel": "335.8 74.4% 35.3%" - }, - { - "scale": 900, - "hex": "#831843", - "rgb": "rgb(131,24,67)", - "hsl": "hsl(335.9,69%,30.4%)", - "oklch": "oklch(0.41,0.14,2)", - "rgbChannel": "131 24 67", - "hslChannel": "335.9 69% 30.4%" - }, - { - "scale": 950, - "hex": "#500724", - "rgb": "rgb(80,7,36)", - "hsl": "hsl(336.2,83.9%,17.1%)", - "oklch": "oklch(0.28,0.10,4)", - "rgbChannel": "80 7 36", - "hslChannel": "336.2 83.9% 17.1%" - } - ], - "rose": [ - { - "scale": 50, - "hex": "#fff1f2", - "rgb": "rgb(255,241,242)", - "hsl": "hsl(355.7,100%,97.3%)", - "oklch": "oklch(0.97,0.02,12)", - "rgbChannel": "255 241 242", - "hslChannel": "355.7 100% 97.3%" - }, - { - "scale": 100, - "hex": "#ffe4e6", - "rgb": "rgb(255,228,230)", - "hsl": "hsl(355.6,100%,94.7%)", - "oklch": "oklch(0.94,0.03,13)", - "rgbChannel": "255 228 230", - "hslChannel": "355.6 100% 94.7%" - }, - { - "scale": 200, - "hex": "#fecdd3", - "rgb": "rgb(254,205,211)", - "hsl": "hsl(352.7,96.1%,90%)", - "oklch": "oklch(0.89,0.06,10)", - "rgbChannel": "254 205 211", - "hslChannel": "352.7 96.1% 90%" - }, - { - "scale": 300, - "hex": "#fda4af", - "rgb": "rgb(253,164,175)", - "hsl": "hsl(352.6,95.7%,81.8%)", - "oklch": "oklch(0.81,0.11,12)", - "rgbChannel": "253 164 175", - "hslChannel": "352.6 95.7% 81.8%" - }, - { - "scale": 400, - "hex": "#fb7185", - "rgb": "rgb(251,113,133)", - "hsl": "hsl(351.3,94.5%,71.4%)", - "oklch": "oklch(0.72,0.17,13)", - "rgbChannel": "251 113 133", - "hslChannel": "351.3 94.5% 71.4%" - }, - { - "scale": 500, - "hex": "#f43f5e", - "rgb": "rgb(244,63,94)", - "hsl": "hsl(349.7,89.2%,60.2%)", - "oklch": "oklch(0.65,0.22,16)", - "rgbChannel": "244 63 94", - "hslChannel": "349.7 89.2% 60.2%" - }, - { - "scale": 600, - "hex": "#e11d48", - "rgb": "rgb(225,29,72)", - "hsl": "hsl(346.8,77.2%,49.8%)", - "oklch": "oklch(0.59,0.22,18)", - "rgbChannel": "225 29 72", - "hslChannel": "346.8 77.2% 49.8%" - }, - { - "scale": 700, - "hex": "#be123c", - "rgb": "rgb(190,18,60)", - "hsl": "hsl(345.3,82.7%,40.8%)", - "oklch": "oklch(0.51,0.20,17)", - "rgbChannel": "190 18 60", - "hslChannel": "345.3 82.7% 40.8%" - }, - { - "scale": 800, - "hex": "#9f1239", - "rgb": "rgb(159,18,57)", - "hsl": "hsl(343.4,79.7%,34.7%)", - "oklch": "oklch(0.45,0.17,14)", - "rgbChannel": "159 18 57", - "hslChannel": "343.4 79.7% 34.7%" - }, - { - "scale": 900, - "hex": "#881337", - "rgb": "rgb(136,19,55)", - "hsl": "hsl(341.5,75.5%,30.4%)", - "oklch": "oklch(0.41,0.15,10)", - "rgbChannel": "136 19 55", - "hslChannel": "341.5 75.5% 30.4%" - }, - { - "scale": 950, - "hex": "#4c0519", - "rgb": "rgb(76,5,25)", - "hsl": "hsl(343.1,87.7%,15.9%)", - "oklch": "oklch(0.27,0.10,12)", - "rgbChannel": "76 5 25", - "hslChannel": "343.1 87.7% 15.9%" - } - ] -} diff --git a/vendor/gpui-component/crates/ui/src/theme/default-theme.json b/vendor/gpui-component/crates/ui/src/theme/default-theme.json deleted file mode 100644 index 2e19e66f..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/default-theme.json +++ /dev/null @@ -1,421 +0,0 @@ -{ - "$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json", - "name": "Default", - "author": "shadcn", - "url": "https://ui.shadcn.com", - "_ref": "https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/colors/neutral.json", - "themes": [ - { - "is_default": true, - "name": "Default Light", - "mode": "light", - "colors": { - "accent.background": "neutral-100", - "accent.foreground": "neutral-900", - "accordion.background": "white", - "background": "white", - "border": "neutral-200", - "group_box.background": "#f5f5f5", - "group_box.foreground": "#171717", - "caret": "#0a0a0a", - "chart_1": "#93c5fd", - "chart_2": "#3b82f6", - "chart_3": "#2563eb", - "chart_4": "#1d4ed8", - "chart_5": "#1e40af", - "chart_bullish": "green-600", - "chart_bearish": "red-600", - "danger.background": "red-500", - "danger.foreground": "neutral-50", - "description_list_label.foreground": "#171717", - "drag_border": "#3b82f6", - "drop_target.background": "#3b82f640", - "foreground": "neutral-950", - "info.background": "cyan-500", - "info.foreground": "neutral-50", - "input.border": "neutral-200", - "link.foreground": "#0a0a0a", - "link.active.foreground": "#0a0a0a", - "link.hover.foreground": "#404040", - "list.background": "white", - "list.active.background": "#bfdbfe33", - "list.active.border": "#60a5fa", - "list.even.background": "#fafafa", - "list.head.background": "#fafafa", - "list.hover.background": "#f5f5f5", - "muted.background": "neutral-100", - "muted.foreground": "neutral-500", - "popover.background": "white", - "popover.foreground": "neutral-950", - "primary.background": "neutral-900", - "primary.active.background": "neutral-950", - "primary.foreground": "neutral-50", - "primary.hover.background": "neutral-800", - "progress_bar.background": "#171717", - "ring": "neutral-950", - "scrollbar.background": "#fafafa00", - "scrollbar.thumb.background": "#a3a3a3e6", - "scrollbar.thumb.hover.background": "#a3a3a3", - "secondary.background": "neutral-200", - "secondary.active.background": "neutral-300", - "secondary.foreground": "neutral-900", - "secondary.hover.background": "neutral-200", - "selection.background": "#55a0fc", - "sidebar.background": "#fafafa", - "sidebar.accent.background": "#e5e5e5", - "sidebar.accent.foreground": "#171717", - "sidebar.border": "#e5e5e5", - "sidebar.foreground": "#171717", - "sidebar.primary.background": "#171717", - "sidebar.primary.foreground": "#fafafa", - "skeleton.background": "#f5f5f5", - "slider.bar.background": "#171717", - "slider.thumb.background": "white", - "success.background": "green-500", - "success.foreground": "neutral-50", - "switch.background": "#d4d4d4", - "tab.background": "#00000000", - "tab.active.background": "white", - "tab.active.foreground": "#171717", - "tab_bar.background": "#f5f5f5", - "tab_bar.segmented.background": "#f5f5f5", - "tab.foreground": "#404040", - "table.background": "white", - "table.active.background": "#bfdbfe33", - "table.active.border": "#60a5fa", - "table.even.background": "#fafafa", - "table.head.background": "#fafafa", - "table.head.foreground": "#737373", - "table.hover.background": "#f5f5f5", - "table.row.border": "#e5e5e5b3", - "tiles.background": "#fafafa", - "title_bar.background": "#F8F8F8", - "title_bar.border": "#e5e5e5", - "warning.background": "yellow-500", - "warning.foreground": "neutral-50", - "overlay": "#0000000d", - "window.border": "#e5e5e5", - "base.red": "red-600", - "base.red.light": "red-400", - "base.green": "green-600", - "base.green.light": "green-400", - "base.blue": "blue-600", - "base.blue.light": "blue-400", - "base.yellow": "yellow-600", - "base.yellow.light": "yellow-400", - "base.magenta": "purple-600", - "base.magenta.light": "purple-400", - "base.cyan": "cyan-600", - "base.cyan.light": "cyan-400" - }, - "highlight": { - "editor.foreground": "#000000", - "editor.background": "#ffffff", - "editor.active_line.background": "#F5F5F5", - "editor.line_number": "#929292", - "editor.active_line_number": "#000000", - "editor.invisible": "#73737366", - "conflict": "#C5060B", - "created": "#1642FF", - "hidden": "#6D6D6D", - "hint": "#9e5dff", - "modified": "#9e7008", - "predictive": "#A4ABB6", - "warning": "#C99401", - "syntax": { - "attribute": { - "color": "#957931" - }, - "boolean": { - "color": "#C5060B" - }, - "comment": { - "color": "#007fff" - }, - "comment.doc": { - "color": "#007fff" - }, - "constant": { - "color": "#C5060B" - }, - "constructor": { - "color": "#0433ff" - }, - "embedded": { - "color": "#333333" - }, - "emphasis": { - "font_style": "italic" - }, - "emphasis.strong": { - "font_weight": 700 - }, - "function": { - "color": "#0000A2" - }, - "keyword": { - "color": "#0433ff" - }, - "link_text": { - "color": "#0000A2", - "font_style": "normal" - }, - "link_uri": { - "color": "#6A7293", - "font_style": "italic" - }, - "number": { - "color": "#0433ff" - }, - "string": { - "color": "#036A07" - }, - "string.escape": { - "color": "#036A07" - }, - "string.regex": { - "color": "#036A07" - }, - "string.special": { - "color": "#d21f07" - }, - "string.special.symbol": { - "color": "#d21f07" - }, - "tag": { - "color": "#0433ff" - }, - "text.literal": { - "color": "#6F42C1" - }, - "text.code.span": { - "color": "#6F42C1" - }, - "title": { - "color": "#0433FF" - }, - "type": { - "color": "#6f42c1" - }, - "property": { - "color": "#333333" - }, - "variable": { - "color": "#333333" - }, - "variable.special": { - "color": "#C5060B" - } - } - } - }, - { - "is_default": true, - "name": "Default Dark", - "mode": "dark", - "colors": { - "accent.background": "neutral-800", - "accent.foreground": "neutral-50", - "accordion.background": "#0a0a0a", - "background": "neutral-950", - "border": "neutral-800", - "group_box.background": "neutral-950", - "group_box.foreground": "neutral-50", - "caret": "#fafafa", - "chart_1": "#93c5fd", - "chart_2": "#3b82f6", - "chart_3": "#2563eb", - "chart_4": "#1d4ed8", - "chart_5": "#1e40af", - "chart_bullish": "green-600", - "chart_bearish": "red-600", - "danger.background": "red-400", - "danger.foreground": "red-600", - "description_list_label.background": "#171717", - "description_list_label.foreground": "#f5f5f5", - "drag_border": "#3b82f6", - "drop_target.background": "#3b82f619", - "foreground": "neutral-50", - "info.background": "cyan-400", - "info.foreground": "cyan-600", - "input.border": "#2f2f2f", - "link.foreground": "#fafafa", - "link.active.foreground": "#d4d4d4", - "link.hover.foreground": "#ffffff", - "list.background": "#0a0a0a", - "list.active.background": "#1e40af33", - "list.active.border": "#1d4ed8", - "list.even.background": "#17171766", - "list.head.background": "#17171766", - "muted.background": "neutral-800", - "muted.foreground": "neutral-400", - "popover.background": "neutral-950", - "popover.foreground": "neutral-50", - "primary.background": "neutral-50", - "primary.active.background": "neutral-200", - "primary.foreground": "neutral-900", - "primary.hover.background": "neutral-100", - "progress_bar.background": "#f5f5f5", - "ring": "neutral-300", - "scrollbar.background": "#17171700", - "scrollbar.thumb.background": "#525252e6", - "scrollbar.thumb.hover.background": "#525252", - "secondary.background": "neutral-800", - "secondary.active.background": "#212121", - "secondary.foreground": "neutral-50", - "secondary.hover.background": "#292929", - "selection.background": "#1d4ed8", - "sidebar.background": "#0a0a0a", - "sidebar.accent.background": "#262626", - "sidebar.accent.foreground": "#f5f5f5", - "sidebar.border": "#262626", - "sidebar.foreground": "#f5f5f5", - "sidebar.primary.background": "#f5f5f5", - "sidebar.primary.foreground": "#0a0a0a", - "skeleton.background": "#171717", - "slider.bar.background": "#fafafa", - "slider.thumb.background": "#0a0a0a", - "success.background": "green-400", - "success.foreground": "green-600", - "switch.background": "#404040", - "tab.background": "#00000000", - "tab.active.background": "#0a0a0a", - "tab.active.foreground": "#fafafa", - "tab_bar.background": "#171717", - "tab_bar.segmented.background": "#171717", - "tab.foreground": "#d4d4d4", - "table.background": "#0a0a0a", - "table.head.foreground": "#525252", - "table.row.border": "#262626b3", - "tiles.background": "#171717", - "title_bar.background": "#171717", - "title_bar.border": "#262626", - "warning.background": "yellow-400", - "warning.foreground": "yellow-600", - "overlay": "#00000033", - "window.border": "#262626", - "base.red": "red-400", - "base.red.light": "red-300", - "base.green": "green-400", - "base.green.light": "green-300", - "base.blue": "blue-400", - "base.blue.light": "blue-300", - "base.yellow": "yellow-400", - "base.yellow.light": "yellow-300", - "base.magenta": "purple-400", - "base.magenta.light": "purple-300", - "base.cyan": "cyan-400", - "base.cyan.light": "cyan-300" - }, - "highlight": { - "editor.foreground": "#CACCCA", - "editor.background": "#0a0a0a", - "editor.active_line.background": "#171717", - "editor.line_number": "#8F8F8F", - "editor.active_line_number": "#DDDDDD", - "editor.invisible": "#73737366", - "conflict": "#D2602D", - "created": "#3f72e2", - "created.background": "#0C4619", - "deleted.background": "#46190C", - "error.background": "#46190C", - "error.border": "#802207", - "hidden": "#9E9E9E", - "hint": "#b283f8", - "hint.background": "#250c4b", - "hint.border": "#3f0891", - "info.background": "#0059D1", - "info.border": "#0059D1", - "modified": "#B0A878", - "modified.background": "#3A310E", - "predictive": "#5D5945", - "success.background": "#0C4619", - "warning.background": "#3A310E", - "warning.border": "#7B6508", - "syntax": { - "attribute": { - "color": "#e7cb8f" - }, - "boolean": { - "color": "#E1D797" - }, - "comment": { - "color": "#9E9E9E" - }, - "comment.doc": { - "color": "#9E9E9E" - }, - "constant": { - "color": "#E1D797" - }, - "constructor": { - "color": "#b5af9a" - }, - "embedded": { - "color": "#CACCCA" - }, - "emphasis": { - "font_style": "italic" - }, - "emphasis.strong": { - "font_weight": 700 - }, - "function": { - "color": "#fdd888" - }, - "keyword": { - "color": "#c28b12" - }, - "link_text": { - "color": "#307BF6", - "font_style": "normal" - }, - "link_uri": { - "color": "#7faef9", - "font_style": "italic" - }, - "number": { - "color": "#E1D797" - }, - "string": { - "color": "#62BA46" - }, - "string.escape": { - "color": "#62BA46" - }, - "string.regex": { - "color": "#62BA46" - }, - "string.special": { - "color": "#E1D797" - }, - "string.special.symbol": { - "color": "#E1D797" - }, - "tag": { - "color": "#b5af9a" - }, - "text.literal": { - "color": "#E1D797" - }, - "text.code.span": { - "color": "#E1D797" - }, - "title": { - "color": "#fdd888", - "font_weight": 600 - }, - "type": { - "color": "#c75828" - }, - "property": { - "color": "#CACCCA" - }, - "variable.special": { - "color": "#E19773" - } - } - } - } - ] -} diff --git a/vendor/gpui-component/crates/ui/src/theme/mod.rs b/vendor/gpui-component/crates/ui/src/theme/mod.rs deleted file mode 100644 index 82b012d7..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/mod.rs +++ /dev/null @@ -1,283 +0,0 @@ -use crate::{ - highlighter::HighlightTheme, list::ListSettings, notification::NotificationSettings, - scroll::ScrollbarShow, sheet::SheetSettings, -}; -use gpui::{App, Global, Hsla, Pixels, SharedString, Window, WindowAppearance, px}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{ - ops::{Deref, DerefMut}, - rc::Rc, - sync::Arc, -}; - -mod color; -mod registry; -mod schema; -mod theme_color; - -pub use color::*; -pub use registry::*; -pub use schema::*; -pub use theme_color::*; - -pub fn init(cx: &mut App) { - registry::init(cx); - - // Ensure theme is loaded directly on startup for WASM compatibility - Theme::change(ThemeMode::Light, None, cx); - Theme::sync_scrollbar_appearance(cx); -} - -pub trait ActiveTheme { - fn theme(&self) -> &Theme; -} - -impl ActiveTheme for App { - #[inline(always)] - fn theme(&self) -> &Theme { - Theme::global(self) - } -} - -/// The global theme configuration. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Theme { - pub colors: ThemeColor, - pub highlight_theme: Arc, - pub light_theme: Rc, - pub dark_theme: Rc, - - pub mode: ThemeMode, - /// The font family for the application, default is `.SystemUIFont`. - pub font_family: SharedString, - /// The base font size for the application, default is 16px. - pub font_size: Pixels, - /// The monospace font family for the application. - /// - /// Defaults to: - /// - /// - macOS: `Menlo` - /// - Windows: `Consolas` - /// - Linux: `DejaVu Sans Mono` - pub mono_font_family: SharedString, - /// The monospace font size for the application, default is 13px. - pub mono_font_size: Pixels, - /// Radius for the general elements. - pub radius: Pixels, - /// Radius for the large elements, e.g.: Dialog, Notification border radius. - pub radius_lg: Pixels, - pub shadow: bool, - pub transparent: Hsla, - /// Show the scrollbar mode, default: Scrolling - pub scrollbar_show: ScrollbarShow, - /// The notification setting. - #[serde(skip)] - pub notification: NotificationSettings, - /// Tile grid size, default is 4px. - pub tile_grid_size: Pixels, - /// The shadow of the tile panel. - pub tile_shadow: bool, - /// The border radius of the tile panel, default is 0px. - pub tile_radius: Pixels, - /// The list settings. - pub list: ListSettings, - /// The sheet settings. - pub sheet: SheetSettings, -} - -impl Default for Theme { - fn default() -> Self { - Self::from(&ThemeColor::default()) - } -} - -impl Deref for Theme { - type Target = ThemeColor; - - fn deref(&self) -> &Self::Target { - &self.colors - } -} - -impl DerefMut for Theme { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.colors - } -} - -impl Global for Theme {} - -impl Theme { - /// Returns the global theme reference - #[inline(always)] - pub fn global(cx: &App) -> &Theme { - cx.global::() - } - - /// Returns the global theme mutable reference - #[inline(always)] - pub fn global_mut(cx: &mut App) -> &mut Theme { - cx.global_mut::() - } - - /// Returns true if the theme is dark. - #[inline(always)] - pub fn is_dark(&self) -> bool { - self.mode.is_dark() - } - - /// Returns the current theme name. - pub fn theme_name(&self) -> &SharedString { - if self.is_dark() { - &self.dark_theme.name - } else { - &self.light_theme.name - } - } - - /// Sync the theme with the system appearance - pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) { - // Better use window.appearance() for avoid error on Linux. - // https://github.com/longbridge/gpui-component/issues/104 - let appearance = window - .as_ref() - .map(|window| window.appearance()) - .unwrap_or_else(|| cx.window_appearance()); - - Self::change(appearance, window, cx); - } - - /// Sync the Scrollbar showing behavior with the system - pub fn sync_scrollbar_appearance(cx: &mut App) { - Theme::global_mut(cx).scrollbar_show = if cx.should_auto_hide_scrollbars() { - ScrollbarShow::Scrolling - } else { - ScrollbarShow::Hover - }; - } - - /// Change the theme mode. - pub fn change(mode: impl Into, window: Option<&mut Window>, cx: &mut App) { - let mode = mode.into(); - if !cx.has_global::() { - let mut theme = Theme::default(); - theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone(); - theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone(); - cx.set_global(theme); - } - - let theme = cx.global_mut::(); - theme.mode = mode; - if mode.is_dark() { - theme.apply_config(&theme.dark_theme.clone()); - } else { - theme.apply_config(&theme.light_theme.clone()); - } - - if let Some(window) = window { - window.refresh(); - } - } - - /// Get the input background color. - /// - /// For dark, use a transparent color mixed with the input border: `cx.theme().input`, - /// otherwise use the `cx.theme().background` color. - #[inline] - pub fn input_background(&self) -> Hsla { - if self.is_dark() { - self.input.mix_oklab(self.transparent, 0.3) - } else { - self.background - } - } - - /// Get the editor background color, if not set, use the input background color. - #[inline] - pub(crate) fn editor_background(&self) -> Hsla { - self.highlight_theme - .style - .editor_background - .unwrap_or_else(|| self.input_background()) - } -} - -impl From<&ThemeColor> for Theme { - fn from(colors: &ThemeColor) -> Self { - Theme { - mode: ThemeMode::default(), - transparent: Hsla::transparent_black(), - font_family: ".SystemUIFont".into(), - font_size: px(16.), - mono_font_family: if cfg!(target_os = "macos") { - // https://en.wikipedia.org/wiki/Menlo_(typeface) - "Menlo".into() - } else if cfg!(target_os = "windows") { - "Consolas".into() - } else { - "DejaVu Sans Mono".into() - }, - mono_font_size: px(13.), - radius: px(6.), - radius_lg: px(8.), - shadow: true, - scrollbar_show: ScrollbarShow::default(), - notification: NotificationSettings::default(), - tile_grid_size: px(8.), - tile_shadow: true, - tile_radius: px(0.), - list: ListSettings::default(), - colors: *colors, - light_theme: Rc::new(ThemeConfig::default()), - dark_theme: Rc::new(ThemeConfig::default()), - highlight_theme: HighlightTheme::default_light(), - sheet: SheetSettings::default(), - } - } -} - -#[derive( - Debug, - Clone, - Copy, - Default, - PartialEq, - PartialOrd, - Eq, - Ord, - Hash, - Serialize, - Deserialize, - JsonSchema, -)] -#[serde(rename_all = "snake_case")] -pub enum ThemeMode { - #[default] - Light, - Dark, -} - -impl ThemeMode { - #[inline(always)] - pub fn is_dark(&self) -> bool { - matches!(self, Self::Dark) - } - - /// Return lower_case theme name: `light`, `dark`. - pub fn name(&self) -> &'static str { - match self { - ThemeMode::Light => "light", - ThemeMode::Dark => "dark", - } - } -} - -impl From for ThemeMode { - fn from(appearance: WindowAppearance) -> Self { - match appearance { - WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark, - WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light, - } - } -} diff --git a/vendor/gpui-component/crates/ui/src/theme/registry.rs b/vendor/gpui-component/crates/ui/src/theme/registry.rs deleted file mode 100644 index e313cf8e..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/registry.rs +++ /dev/null @@ -1,287 +0,0 @@ -use crate::{Theme, ThemeColor, ThemeConfig, ThemeMode, ThemeSet, highlighter::HighlightTheme}; -#[allow(unused)] -use anyhow::Result; -use gpui::{App, Global, SharedString}; -use std::{ - collections::HashMap, - path::PathBuf, - rc::Rc, - sync::{Arc, LazyLock}, -}; - -const DEFAULT_THEME: &str = include_str!("./default-theme.json"); -pub(crate) static DEFAULT_THEME_COLORS: LazyLock< - HashMap, Arc)>, -> = LazyLock::new(|| { - let mut colors = HashMap::new(); - - let themes: Vec = serde_json::from_str::(DEFAULT_THEME) - .expect("Failed to parse themes/default.json") - .themes; - - for theme in themes { - let mut theme_color = ThemeColor::default(); - theme_color.apply_config(&theme, &ThemeColor::default()); - - let highlight_theme = HighlightTheme { - name: theme.name.to_string(), - appearance: theme.mode, - style: theme.highlight.unwrap_or_default(), - }; - - colors.insert( - theme.mode, - (Arc::new(theme_color), Arc::new(highlight_theme)), - ); - } - - colors -}); - -pub(super) fn init(cx: &mut App) { - cx.set_global(ThemeRegistry::default()); - ThemeRegistry::global_mut(cx).init_default_themes(); - - // Observe changes to the theme registry to apply changes to the active theme - cx.observe_global::(|cx| { - let mode = Theme::global(cx).mode; - let light_theme = Theme::global(cx).light_theme.name.clone(); - let dark_theme = Theme::global(cx).dark_theme.name.clone(); - - if let Some(theme) = ThemeRegistry::global(cx) - .themes() - .get(&light_theme) - .cloned() - { - Theme::global_mut(cx).light_theme = theme; - } - if let Some(theme) = ThemeRegistry::global(cx).themes().get(&dark_theme).cloned() { - Theme::global_mut(cx).dark_theme = theme; - } - - let theme_name = if mode.is_dark() { - dark_theme - } else { - light_theme - }; - - tracing::info!("Reload active theme: {:?}...", theme_name); - Theme::change(mode, None, cx); - cx.refresh_windows(); - }) - .detach(); -} - -#[derive(Default, Debug)] -pub struct ThemeRegistry { - themes_dir: PathBuf, - default_themes: HashMap>, - themes: HashMap>, - has_custom_themes: bool, -} - -impl Global for ThemeRegistry {} - -impl ThemeRegistry { - pub fn global(cx: &App) -> &Self { - cx.global::() - } - - pub fn global_mut(cx: &mut App) -> &mut Self { - cx.global_mut::() - } - - /// Watch themes directory. - /// - /// And reload themes to trigger the `on_load` callback. - #[cfg(not(target_family = "wasm"))] - pub fn watch_dir(themes_dir: PathBuf, cx: &mut App, on_load: F) -> Result<()> - where - F: Fn(&mut App) + 'static, - { - Self::global_mut(cx).themes_dir = themes_dir.clone(); - - // Load theme in the background. - cx.spawn(async move |cx| { - _ = cx.update(|cx| { - if let Err(err) = Self::_watch_themes_dir(themes_dir, cx) { - tracing::error!("Failed to watch themes directory: {}", err); - } - - Self::reload_themes(cx); - on_load(cx); - }); - }) - .detach(); - - Ok(()) - } - - /// Returns a reference to the map of themes (including default themes). - pub fn themes(&self) -> &HashMap> { - &self.themes - } - - /// Returns a sorted list of themes. - pub fn sorted_themes(&self) -> Vec<&Rc> { - let mut themes = self.themes.values().collect::>(); - // sort by is_default true first, then light first dark later, then by name case-insensitive - themes.sort_by(|a, b| { - b.is_default - .cmp(&a.is_default) - .then(a.mode.cmp(&b.mode)) - .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - themes - } - - /// Returns a reference to the map of default themes. - pub fn default_themes(&self) -> &HashMap> { - &self.default_themes - } - - pub fn default_light_theme(&self) -> &Rc { - &self.default_themes[&ThemeMode::Light] - } - - pub fn default_dark_theme(&self) -> &Rc { - &self.default_themes[&ThemeMode::Dark] - } - - pub fn load_themes_from_str(&mut self, content: &str) -> anyhow::Result<()> { - let theme_set = serde_json::from_str::(content)?; - for theme in theme_set.themes { - if !self.themes.contains_key(&theme.name) { - let theme_name = theme.name.clone(); - self.themes.insert(theme_name, Rc::new(theme)); - self.has_custom_themes = true; - } - } - Ok(()) - } - - fn init_default_themes(&mut self) { - let default_themes: Vec = serde_json::from_str::(DEFAULT_THEME) - .expect("failed to parse default theme.") - .themes; - for theme in default_themes.into_iter() { - if theme.mode.is_dark() { - self.default_themes.insert(ThemeMode::Dark, Rc::new(theme)); - } else { - self.default_themes.insert(ThemeMode::Light, Rc::new(theme)); - } - } - self.themes_dir = PathBuf::from("./themes"); - self.themes = self - .default_themes - .values() - .map(|theme| { - let name = theme.name.clone(); - (name, Rc::clone(theme)) - }) - .collect(); - } - - #[cfg(not(target_family = "wasm"))] - fn _watch_themes_dir(themes_dir: PathBuf, cx: &mut App) -> anyhow::Result<()> { - if !themes_dir.exists() { - std::fs::create_dir_all(&themes_dir)?; - } - - let (tx, rx) = smol::channel::bounded(100); - let mut watcher = - notify::recommended_watcher(move |res: notify::Result| { - if let Ok(event) = &res { - match event.kind { - notify::EventKind::Create(_) - | notify::EventKind::Modify(_) - | notify::EventKind::Remove(_) => { - if let Err(err) = tx.send_blocking(res) { - tracing::error!("Failed to send theme event: {:?}", err); - } - } - _ => {} - } - } - })?; - - cx.spawn(async move |cx| { - use notify::Watcher as _; - - if let Err(err) = watcher.watch(&themes_dir, notify::RecursiveMode::Recursive) { - tracing::error!("Failed to watch themes directory: {:?}", err); - } - - while (rx.recv().await).is_ok() { - tracing::info!("Reloading themes..."); - _ = cx.update(Self::reload_themes); - } - }) - .detach(); - - Ok(()) - } - - #[cfg(not(target_family = "wasm"))] - fn reload_themes(cx: &mut App) { - let registry = Self::global_mut(cx); - match registry.reload() { - Ok(_) => { - tracing::info!("Themes reloaded successfully."); - } - Err(e) => tracing::error!("Failed to reload themes: {:?}", e), - } - } - - #[cfg(not(target_family = "wasm"))] - /// Reload themes from the `themes_dir`. - fn reload(&mut self) -> Result<()> { - let mut themes = vec![]; - - if self.themes_dir.exists() { - for entry in std::fs::read_dir(&self.themes_dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") { - let file_content = std::fs::read_to_string(path.clone())?; - - match serde_json::from_str::(&file_content) { - Ok(theme_set) => { - themes.extend(theme_set.themes); - } - Err(e) => { - tracing::error!( - "ignored invalid theme file: {}, {}", - path.display(), - e - ); - } - } - } - } - } - - self.themes.clear(); - for theme in self.default_themes.values() { - self.themes - .insert(theme.name.clone(), Rc::new((**theme).clone())); - } - - for theme in themes.iter() { - if self.themes.contains_key(&theme.name) { - continue; - } - - if theme.is_default { - self.default_themes - .insert(theme.mode, Rc::new(theme.clone())); - } - - self.has_custom_themes = true; - self.themes - .insert(theme.name.clone(), Rc::new(theme.clone())); - } - - Ok(()) - } -} diff --git a/vendor/gpui-component/crates/ui/src/theme/schema.rs b/vendor/gpui-component/crates/ui/src/theme/schema.rs deleted file mode 100644 index c936be87..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/schema.rs +++ /dev/null @@ -1,715 +0,0 @@ -use std::{rc::Rc, sync::Arc}; - -use gpui::{SharedString, px}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::{ - Colorize, Theme, ThemeColor, ThemeMode, - highlighter::{HighlightTheme, HighlightThemeStyle}, - try_parse_color, -}; - -/// Represents a theme configuration. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(default)] -pub struct ThemeSet { - /// The name of the theme set. - pub name: SharedString, - /// The author of the theme. - pub author: Option, - /// The URL of the theme. - pub url: Option, - /// The theme list of the theme set. - #[serde(rename = "themes")] - pub themes: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(default)] -pub struct ThemeConfig { - /// Whether this theme is the default theme. - pub is_default: bool, - /// The name of the theme. - pub name: SharedString, - /// The mode of the theme, default is light. - pub mode: ThemeMode, - - /// The base font size, default is 16. - #[serde(rename = "font.size")] - pub font_size: Option, - /// The base font family, default is system font: `.SystemUIFont`. - #[serde(rename = "font.family")] - pub font_family: Option, - /// The monospace font family, default is platform specific: - /// - macOS: `Menlo` - /// - Windows: `Consolas` - /// - Linux: `DejaVu Sans Mono` - #[serde(rename = "mono_font.family")] - pub mono_font_family: Option, - /// The monospace font size, default is 13. - #[serde(rename = "mono_font.size")] - pub mono_font_size: Option, - - /// The border radius for general elements, default is 6. - #[serde(rename = "radius")] - pub radius: Option, - /// The border radius for large elements like Dialogs and Notifications, default is 8. - #[serde(rename = "radius.lg")] - pub radius_lg: Option, - /// Set shadows in the theme, for example the Input and Button, default is true. - #[serde(rename = "shadow")] - pub shadow: Option, - - /// The colors of the theme. - pub colors: ThemeConfigColors, - /// The highlight theme, this part is combilbility with `style` section in Zed theme. - /// - /// https://github.com/zed-industries/zed/blob/f50041779dcfd7a76c8aec293361c60c53f02d51/assets/themes/ayu/ayu.json#L9 - pub highlight: Option, -} - -#[derive(Debug, Default, Clone, JsonSchema, Serialize, Deserialize)] -pub struct ThemeConfigColors { - /// Used for accents such as hover background on MenuItem, ListItem, etc. - #[serde(rename = "accent.background")] - pub accent: Option, - /// Used for accent text color. - #[serde(rename = "accent.foreground")] - pub accent_foreground: Option, - /// Accordion background color. - #[serde(rename = "accordion.background")] - pub accordion: Option, - /// Accordion hover background color. - #[serde(rename = "accordion.hover.background")] - pub accordion_hover: Option, - /// Default background color. - #[serde(rename = "background")] - pub background: Option, - /// Default border color - #[serde(rename = "border")] - pub border: Option, - /// Button primary background color, fallback to `primary`. - #[serde(rename = "button.primary.background")] - pub button_primary: Option, - /// Button primary active background color, fallback to `primary_active`. - #[serde(rename = "button.primary.active.background")] - pub button_primary_active: Option, - /// Button primary text color, fallback to `primary_foreground`. - #[serde(rename = "button.primary.foreground")] - pub button_primary_foreground: Option, - /// Button primary hover background color, fallback to `primary_hover`. - #[serde(rename = "button.primary.hover.background")] - pub button_primary_hover: Option, - /// Background color for GroupBox. - #[serde(rename = "group_box.background")] - pub group_box: Option, - /// Text color for GroupBox. - #[serde(rename = "group_box.foreground")] - pub group_box_foreground: Option, - /// Title text color for GroupBox. - #[serde(rename = "group_box.title.foreground")] - pub group_box_title_foreground: Option, - /// Input caret color (Blinking cursor). - #[serde(rename = "caret")] - pub caret: Option, - /// Chart 1 color. - #[serde(rename = "chart.1")] - pub chart_1: Option, - /// Chart 2 color. - #[serde(rename = "chart.2")] - pub chart_2: Option, - /// Chart 3 color. - #[serde(rename = "chart.3")] - pub chart_3: Option, - /// Chart 4 color. - #[serde(rename = "chart.4")] - pub chart_4: Option, - /// Chart 5 color. - #[serde(rename = "chart.5")] - pub chart_5: Option, - /// Bullish color for candlestick charts (upward price movement). - #[serde(rename = "chart_bullish")] - pub chart_bullish: Option, - /// Bearish color for candlestick charts (downward price movement). - #[serde(rename = "chart_bearish")] - pub chart_bearish: Option, - /// Danger background color. - #[serde(rename = "danger.background")] - pub danger: Option, - /// Danger active background color. - #[serde(rename = "danger.active.background")] - pub danger_active: Option, - /// Danger text color. - #[serde(rename = "danger.foreground")] - pub danger_foreground: Option, - /// Danger hover background color. - #[serde(rename = "danger.hover.background")] - pub danger_hover: Option, - /// Description List label background color. - #[serde(rename = "description_list.label.background")] - pub description_list_label: Option, - /// Description List label foreground color. - #[serde(rename = "description_list.label.foreground")] - pub description_list_label_foreground: Option, - /// Drag border color. - #[serde(rename = "drag.border")] - pub drag_border: Option, - /// Drop target background color. - #[serde(rename = "drop_target.background")] - pub drop_target: Option, - /// Default text color. - #[serde(rename = "foreground")] - pub foreground: Option, - /// Info background color. - #[serde(rename = "info.background")] - pub info: Option, - /// Info active background color. - #[serde(rename = "info.active.background")] - pub info_active: Option, - /// Info text color. - #[serde(rename = "info.foreground")] - pub info_foreground: Option, - /// Info hover background color. - #[serde(rename = "info.hover.background")] - pub info_hover: Option, - /// Border color for inputs such as Input, Select, etc. - #[serde(rename = "input.border")] - pub input: Option, - /// Link text color. - #[serde(rename = "link")] - pub link: Option, - /// Active link text color. - #[serde(rename = "link.active")] - pub link_active: Option, - /// Hover link text color. - #[serde(rename = "link.hover")] - pub link_hover: Option, - /// Background color for List and ListItem. - #[serde(rename = "list.background")] - pub list: Option, - /// Background color for active ListItem. - #[serde(rename = "list.active.background")] - pub list_active: Option, - /// Border color for active ListItem. - #[serde(rename = "list.active.border")] - pub list_active_border: Option, - /// Stripe background color for even ListItem. - #[serde(rename = "list.even.background")] - pub list_even: Option, - /// Background color for List header. - #[serde(rename = "list.head.background")] - pub list_head: Option, - /// Hover background color for ListItem. - #[serde(rename = "list.hover.background")] - pub list_hover: Option, - /// Muted backgrounds such as Skeleton and Switch. - #[serde(rename = "muted.background")] - pub muted: Option, - /// Muted text color, as used in disabled text. - #[serde(rename = "muted.foreground")] - pub muted_foreground: Option, - /// Background color for Popover. - #[serde(rename = "popover.background")] - pub popover: Option, - /// Text color for Popover. - #[serde(rename = "popover.foreground")] - pub popover_foreground: Option, - /// Primary background color. - #[serde(rename = "primary.background")] - pub primary: Option, - /// Active primary background color. - #[serde(rename = "primary.active.background")] - pub primary_active: Option, - /// Primary text color. - #[serde(rename = "primary.foreground")] - pub primary_foreground: Option, - /// Hover primary background color. - #[serde(rename = "primary.hover.background")] - pub primary_hover: Option, - /// Progress bar background color. - #[serde(rename = "progress.bar.background")] - pub progress_bar: Option, - /// Used for focus ring. - #[serde(rename = "ring")] - pub ring: Option, - /// Scrollbar background color. - #[serde(rename = "scrollbar.background")] - pub scrollbar: Option, - /// Scrollbar thumb background color. - #[serde(rename = "scrollbar.thumb.background")] - pub scrollbar_thumb: Option, - /// Scrollbar thumb hover background color. - #[serde(rename = "scrollbar.thumb.hover.background")] - pub scrollbar_thumb_hover: Option, - /// Secondary background color. - #[serde(rename = "secondary.background")] - pub secondary: Option, - /// Active secondary background color. - #[serde(rename = "secondary.active.background")] - pub secondary_active: Option, - /// Secondary text color, used for secondary Button text color or secondary text. - #[serde(rename = "secondary.foreground")] - pub secondary_foreground: Option, - /// Hover secondary background color. - #[serde(rename = "secondary.hover.background")] - pub secondary_hover: Option, - /// Input selection background color. - #[serde(rename = "selection.background")] - pub selection: Option, - /// Sidebar background color. - #[serde(rename = "sidebar.background")] - pub sidebar: Option, - /// Sidebar accent background color. - #[serde(rename = "sidebar.accent.background")] - pub sidebar_accent: Option, - /// Sidebar accent text color. - #[serde(rename = "sidebar.accent.foreground")] - pub sidebar_accent_foreground: Option, - /// Sidebar border color. - #[serde(rename = "sidebar.border")] - pub sidebar_border: Option, - /// Sidebar text color. - #[serde(rename = "sidebar.foreground")] - pub sidebar_foreground: Option, - /// Sidebar primary background color. - #[serde(rename = "sidebar.primary.background")] - pub sidebar_primary: Option, - /// Sidebar primary text color. - #[serde(rename = "sidebar.primary.foreground")] - pub sidebar_primary_foreground: Option, - /// Skeleton background color. - #[serde(rename = "skeleton.background")] - pub skeleton: Option, - /// Slider bar background color. - #[serde(rename = "slider.background")] - pub slider_bar: Option, - /// Slider thumb background color. - #[serde(rename = "slider.thumb.background")] - pub slider_thumb: Option, - /// Success background color. - #[serde(rename = "success.background")] - pub success: Option, - /// Success text color. - #[serde(rename = "success.foreground")] - pub success_foreground: Option, - /// Success hover background color. - #[serde(rename = "success.hover.background")] - pub success_hover: Option, - /// Success active background color. - #[serde(rename = "success.active.background")] - pub success_active: Option, - /// Switch background color. - #[serde(rename = "switch.background")] - pub switch: Option, - /// Switch thumb background color. - #[serde(rename = "switch.thumb.background")] - pub switch_thumb: Option, - /// Tab background color. - #[serde(rename = "tab.background")] - pub tab: Option, - /// Tab active background color. - #[serde(rename = "tab.active.background")] - pub tab_active: Option, - /// Tab active text color. - #[serde(rename = "tab.active.foreground")] - pub tab_active_foreground: Option, - /// TabBar background color. - #[serde(rename = "tab_bar.background")] - pub tab_bar: Option, - /// TabBar segmented background color. - #[serde(rename = "tab_bar.segmented.background")] - pub tab_bar_segmented: Option, - /// Tab text color. - #[serde(rename = "tab.foreground")] - pub tab_foreground: Option, - /// Table background color. - #[serde(rename = "table.background")] - pub table: Option, - /// Table active item background color. - #[serde(rename = "table.active.background")] - pub table_active: Option, - /// Table active item border color. - #[serde(rename = "table.active.border")] - pub table_active_border: Option, - /// Stripe background color for even TableRow. - #[serde(rename = "table.even.background")] - pub table_even: Option, - /// Table header background color. - #[serde(rename = "table.head.background")] - pub table_head: Option, - /// Table header text color. - #[serde(rename = "table.head.foreground")] - pub table_head_foreground: Option, - /// Table footer background color. - #[serde(rename = "table.foot.background")] - pub table_foot: Option, - /// Table footer text color. - #[serde(rename = "table.foot.foreground")] - pub table_foot_foreground: Option, - /// Table item hover background color. - #[serde(rename = "table.hover.background")] - pub table_hover: Option, - /// Table row border color. - #[serde(rename = "table.row.border")] - pub table_row_border: Option, - /// TitleBar background color, use for Window title bar. - #[serde(rename = "title_bar.background")] - pub title_bar: Option, - /// TitleBar border color. - #[serde(rename = "title_bar.border")] - pub title_bar_border: Option, - /// Background color for Tiles. - #[serde(rename = "tiles.background")] - pub tiles: Option, - /// Warning background color. - #[serde(rename = "warning.background")] - pub warning: Option, - /// Warning active background color. - #[serde(rename = "warning.active.background")] - pub warning_active: Option, - /// Warning hover background color. - #[serde(rename = "warning.hover.background")] - pub warning_hover: Option, - /// Warning foreground color. - #[serde(rename = "warning.foreground")] - pub warning_foreground: Option, - /// Overlay background color. - #[serde(rename = "overlay")] - pub overlay: Option, - /// Window border color. - /// - /// # Platform specific: - /// - /// This is only works on Linux, other platforms we can't change the window border color. - #[serde(rename = "window.border")] - pub window_border: Option, - - /// Base blue color. - #[serde(rename = "base.blue")] - blue: Option, - /// Base light blue color. - #[serde(rename = "base.blue.light")] - blue_light: Option, - /// Base cyan color. - #[serde(rename = "base.cyan")] - cyan: Option, - /// Base light cyan color. - #[serde(rename = "base.cyan.light")] - cyan_light: Option, - /// Base green color. - #[serde(rename = "base.green")] - green: Option, - /// Base light green color. - #[serde(rename = "base.green.light")] - green_light: Option, - /// Base magenta color. - #[serde(rename = "base.magenta")] - magenta: Option, - #[serde(rename = "base.magenta.light")] - magenta_light: Option, - /// Base red color. - #[serde(rename = "base.red")] - red: Option, - /// Base light red color. - #[serde(rename = "base.red.light")] - red_light: Option, - /// Base yellow color. - #[serde(rename = "base.yellow")] - yellow: Option, - /// Base light yellow color. - #[serde(rename = "base.yellow.light")] - yellow_light: Option, -} - -impl ThemeColor { - /// Create a new `ThemeColor` from a `ThemeConfig`. - pub(crate) fn apply_config(&mut self, config: &ThemeConfig, default_theme: &ThemeColor) { - let colors = config.colors.clone(); - - macro_rules! apply_color { - ($config_field:ident) => { - if let Some(value) = colors.$config_field { - if let Ok(color) = try_parse_color(&value) { - self.$config_field = color; - } else { - self.$config_field = default_theme.$config_field; - } - } else { - self.$config_field = default_theme.$config_field; - } - }; - // With fallback - ($config_field:ident, fallback = $fallback:expr) => { - if let Some(value) = colors.$config_field { - if let Ok(color) = try_parse_color(&value) { - self.$config_field = color; - } - } else { - self.$config_field = $fallback; - } - }; - } - - apply_color!(background); - - // Base colors for fallback - apply_color!(red); - apply_color!( - red_light, - fallback = self.background.blend(self.red.opacity(0.8)) - ); - apply_color!(green); - apply_color!( - green_light, - fallback = self.background.blend(self.green.opacity(0.8)) - ); - apply_color!(blue); - apply_color!( - blue_light, - fallback = self.background.blend(self.blue.opacity(0.8)) - ); - apply_color!(magenta); - apply_color!( - magenta_light, - fallback = self.background.blend(self.magenta.opacity(0.8)) - ); - apply_color!(yellow); - apply_color!( - yellow_light, - fallback = self.background.blend(self.yellow.opacity(0.8)) - ); - apply_color!(cyan); - apply_color!( - cyan_light, - fallback = self.background.blend(self.cyan.opacity(0.8)) - ); - - apply_color!(border); - apply_color!(foreground); - apply_color!(muted); - apply_color!( - muted_foreground, - fallback = self.muted.blend(self.foreground.opacity(0.7)) - ); - - // Button colors - let active_darken = if config.mode.is_dark() { 0.2 } else { 0.1 }; - let hover_opacity = 0.9; - apply_color!(primary); - apply_color!(primary_foreground, fallback = self.foreground); - apply_color!( - primary_hover, - fallback = self.background.blend(self.primary.opacity(hover_opacity)) - ); - apply_color!( - primary_active, - fallback = self.primary.darken(active_darken) - ); - apply_color!(button_primary, fallback = self.primary); - apply_color!( - button_primary_foreground, - fallback = self.primary_foreground - ); - apply_color!(button_primary_hover, fallback = self.primary_hover); - apply_color!(button_primary_active, fallback = self.primary_active); - apply_color!(secondary); - apply_color!(secondary_foreground, fallback = self.foreground); - apply_color!( - secondary_hover, - fallback = self.background.blend(self.secondary.opacity(hover_opacity)) - ); - apply_color!( - secondary_active, - fallback = self.secondary.darken(active_darken) - ); - apply_color!(success, fallback = self.green); - apply_color!(success_foreground, fallback = self.primary_foreground); - apply_color!( - success_hover, - fallback = self.background.blend(self.success.opacity(hover_opacity)) - ); - apply_color!( - success_active, - fallback = self.success.darken(active_darken) - ); - apply_color!(info, fallback = self.cyan); - apply_color!(info_foreground, fallback = self.primary_foreground); - apply_color!( - info_hover, - fallback = self.background.blend(self.info.opacity(hover_opacity)) - ); - apply_color!(info_active, fallback = self.info.darken(active_darken)); - apply_color!(warning, fallback = self.yellow); - apply_color!(warning_foreground, fallback = self.primary_foreground); - apply_color!( - warning_hover, - fallback = self.background.blend(self.warning.opacity(0.9)) - ); - apply_color!( - warning_active, - fallback = self.background.blend(self.warning.darken(active_darken)) - ); - - // Other colors - apply_color!(accent, fallback = self.secondary); - apply_color!(accent_foreground, fallback = self.foreground); - apply_color!(accordion, fallback = self.background); - apply_color!(accordion_hover, fallback = self.accent.opacity(0.8)); - apply_color!( - group_box, - fallback = self - .background - .blend( - self.secondary - .opacity(if config.mode.is_dark() { 0.3 } else { 0.4 }) - ) - ); - apply_color!(group_box_foreground, fallback = self.foreground); - apply_color!(caret, fallback = self.primary); - apply_color!(chart_1, fallback = self.blue.lighten(0.4)); - apply_color!(chart_2, fallback = self.blue.lighten(0.2)); - apply_color!(chart_3, fallback = self.blue); - apply_color!(chart_4, fallback = self.blue.darken(0.2)); - apply_color!(chart_5, fallback = self.blue.darken(0.4)); - apply_color!(chart_bullish, fallback = self.green); - apply_color!(chart_bearish, fallback = self.red); - apply_color!(danger, fallback = self.red); - apply_color!(danger_active, fallback = self.danger.darken(active_darken)); - apply_color!(danger_foreground, fallback = self.primary_foreground); - apply_color!( - danger_hover, - fallback = self.background.blend(self.danger.opacity(0.9)) - ); - apply_color!( - description_list_label, - fallback = self.background.blend(self.border.opacity(0.2)) - ); - apply_color!( - description_list_label_foreground, - fallback = self.muted_foreground - ); - apply_color!(drag_border, fallback = self.primary.opacity(0.65)); - apply_color!(drop_target, fallback = self.primary.opacity(0.2)); - apply_color!(input, fallback = self.border); - apply_color!(link, fallback = self.primary); - apply_color!(link_active, fallback = self.link); - apply_color!(link_hover, fallback = self.link); - apply_color!(list, fallback = self.background); - apply_color!( - list_active, - fallback = self.background.blend(self.primary.opacity(0.1)) - ); - apply_color!( - list_active_border, - fallback = self.background.blend(self.primary.opacity(0.6)) - ); - apply_color!(list_even, fallback = self.list); - apply_color!(list_head, fallback = self.list); - apply_color!(list_hover, fallback = self.accent.opacity(0.6)); - apply_color!(popover, fallback = self.background); - apply_color!(popover_foreground, fallback = self.foreground); - apply_color!(progress_bar, fallback = self.primary); - apply_color!(ring, fallback = self.blue); - apply_color!(scrollbar, fallback = self.background); - apply_color!(scrollbar_thumb, fallback = self.accent); - apply_color!(scrollbar_thumb_hover, fallback = self.scrollbar_thumb); - apply_color!(selection, fallback = self.primary); - apply_color!( - sidebar, - fallback = self.background.blend(self.border.opacity(0.15)) - ); - apply_color!(sidebar_accent, fallback = self.accent); - apply_color!(sidebar_accent_foreground, fallback = self.accent_foreground); - apply_color!(sidebar_border, fallback = self.border); - apply_color!(sidebar_foreground, fallback = self.foreground); - apply_color!(sidebar_primary, fallback = self.primary); - apply_color!( - sidebar_primary_foreground, - fallback = self.primary_foreground - ); - apply_color!(skeleton, fallback = self.secondary); - apply_color!(slider_bar, fallback = self.primary); - apply_color!(slider_thumb, fallback = self.primary_foreground); - apply_color!(switch, fallback = self.secondary_active); - apply_color!(switch_thumb, fallback = self.background); - apply_color!(tab, fallback = self.background); - apply_color!(tab_active, fallback = self.background); - apply_color!(tab_active_foreground, fallback = self.foreground); - apply_color!(tab_bar, fallback = self.background); - apply_color!(tab_bar_segmented, fallback = self.secondary); - apply_color!(tab_foreground, fallback = self.foreground); - apply_color!(table, fallback = self.list); - apply_color!(table_active, fallback = self.list_active); - apply_color!(table_active_border, fallback = self.list_active_border); - apply_color!(table_even, fallback = self.list_even); - apply_color!(table_head, fallback = self.list_head); - apply_color!(table_head_foreground, fallback = self.muted_foreground); - apply_color!(table_foot, fallback = self.list_head); - apply_color!(table_foot_foreground, fallback = self.muted_foreground); - apply_color!(table_hover, fallback = self.list_hover); - apply_color!(table_row_border, fallback = self.border); - apply_color!(title_bar, fallback = self.background); - apply_color!(title_bar_border, fallback = self.border); - apply_color!(tiles, fallback = self.background); - apply_color!(overlay); - apply_color!(window_border, fallback = self.border); - - // TODO: Apply default fallback colors to highlight. - - // Ensure opacity for list_active, table_active - self.list_active = self.list_active.alpha(self.list_active.a.min(0.2)); - self.table_active = self.table_active.alpha(self.table_active.a.min(0.2)); - self.selection = self.selection.alpha(self.selection.a.min(0.3)); - } -} - -impl Theme { - /// Apply the given theme configuration to the current theme. - pub fn apply_config(&mut self, config: &Rc) { - if config.mode.is_dark() { - self.dark_theme = config.clone(); - } else { - self.light_theme = config.clone(); - } - if let Some(style) = &config.highlight { - let highlight_theme = Arc::new(HighlightTheme { - name: config.name.to_string(), - appearance: config.mode, - style: style.clone(), - }); - self.highlight_theme = highlight_theme.clone(); - } - - let default_colors = if config.mode.is_dark() { - ThemeColor::dark() - } else { - ThemeColor::light() - }; - - if let Some(font_size) = config.font_size { - self.font_size = px(font_size); - } - if let Some(font_family) = &config.font_family { - self.font_family = font_family.clone(); - } - if let Some(mono_font_family) = &config.mono_font_family { - self.mono_font_family = mono_font_family.clone(); - } - if let Some(mono_font_size) = config.mono_font_size { - self.mono_font_size = px(mono_font_size); - } - if let Some(radius) = config.radius { - self.radius = px(radius as f32); - } - if let Some(radius_lg) = config.radius_lg { - self.radius_lg = px(radius_lg as f32); - } - if let Some(shadow) = config.shadow { - self.shadow = shadow; - } - - self.colors.apply_config(&config, &default_colors); - self.mode = config.mode; - } -} diff --git a/vendor/gpui-component/crates/ui/src/theme/theme_color.rs b/vendor/gpui-component/crates/ui/src/theme/theme_color.rs deleted file mode 100644 index 9f7c1b79..00000000 --- a/vendor/gpui-component/crates/ui/src/theme/theme_color.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::sync::Arc; - -use crate::{ThemeMode, theme::DEFAULT_THEME_COLORS}; - -use gpui::Hsla; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// Theme colors used throughout the UI components. -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] -pub struct ThemeColor { - /// Used for accents such as hover background on MenuItem, ListItem, etc. - pub accent: Hsla, - /// Used for accent text color. - pub accent_foreground: Hsla, - /// Accordion background color. - pub accordion: Hsla, - /// Accordion hover background color. - pub accordion_hover: Hsla, - /// Default background color. - pub background: Hsla, - /// Default border color - pub border: Hsla, - /// Button primary background color, fallback to `primary`. - pub button_primary: Hsla, - /// Button primary active background color, fallback to `primary_active`. - pub button_primary_active: Hsla, - /// Button primary text color, fallback to `primary_foreground`. - pub button_primary_foreground: Hsla, - /// Button primary hover background color, fallback to `primary_hover`. - pub button_primary_hover: Hsla, - /// Background color for GroupBox. - pub group_box: Hsla, - /// Text color for GroupBox. - pub group_box_foreground: Hsla, - /// Input caret color (Blinking cursor). - pub caret: Hsla, - /// Chart 1 color. - pub chart_1: Hsla, - /// Chart 2 color. - pub chart_2: Hsla, - /// Chart 3 color. - pub chart_3: Hsla, - /// Chart 4 color. - pub chart_4: Hsla, - /// Chart 5 color. - pub chart_5: Hsla, - /// Bullish color for candlestick charts (upward price movement). - pub chart_bullish: Hsla, - /// Bearish color for candlestick charts (downward price movement). - pub chart_bearish: Hsla, - /// Danger background color. - pub danger: Hsla, - /// Danger active background color. - pub danger_active: Hsla, - /// Danger text color. - pub danger_foreground: Hsla, - /// Danger hover background color. - pub danger_hover: Hsla, - /// Description List label background color. - pub description_list_label: Hsla, - /// Description List label foreground color. - pub description_list_label_foreground: Hsla, - /// Drag border color. - pub drag_border: Hsla, - /// Drop target background color. - pub drop_target: Hsla, - /// Default text color. - pub foreground: Hsla, - /// Info background color. - pub info: Hsla, - /// Info active background color. - pub info_active: Hsla, - /// Info text color. - pub info_foreground: Hsla, - /// Info hover background color. - pub info_hover: Hsla, - /// Border color for inputs such as Input, Select, etc. - pub input: Hsla, - /// Link text color. - pub link: Hsla, - /// Active link text color. - pub link_active: Hsla, - /// Hover link text color. - pub link_hover: Hsla, - /// Background color for List and ListItem. - pub list: Hsla, - /// Background color for active ListItem. - pub list_active: Hsla, - /// Border color for active ListItem. - pub list_active_border: Hsla, - /// Stripe background color for even ListItem. - pub list_even: Hsla, - /// Background color for List header. - pub list_head: Hsla, - /// Hover background color for ListItem. - pub list_hover: Hsla, - /// Muted backgrounds such as Skeleton and Switch. - pub muted: Hsla, - /// Muted text color, as used in disabled text. - pub muted_foreground: Hsla, - /// Background color for Popover. - pub popover: Hsla, - /// Text color for Popover. - pub popover_foreground: Hsla, - /// Primary background color. - pub primary: Hsla, - /// Active primary background color. - pub primary_active: Hsla, - /// Primary text color. - pub primary_foreground: Hsla, - /// Hover primary background color. - pub primary_hover: Hsla, - /// Progress bar background color. - pub progress_bar: Hsla, - /// Used for focus ring. - pub ring: Hsla, - /// Scrollbar background color. - pub scrollbar: Hsla, - /// Scrollbar thumb background color. - pub scrollbar_thumb: Hsla, - /// Scrollbar thumb hover background color. - pub scrollbar_thumb_hover: Hsla, - /// Secondary background color. - pub secondary: Hsla, - /// Active secondary background color. - pub secondary_active: Hsla, - /// Secondary text color, used for secondary Button text color or secondary text. - pub secondary_foreground: Hsla, - /// Hover secondary background color. - pub secondary_hover: Hsla, - /// Input selection background color. - pub selection: Hsla, - /// Sidebar background color. - pub sidebar: Hsla, - /// Sidebar accent background color. - pub sidebar_accent: Hsla, - /// Sidebar accent text color. - pub sidebar_accent_foreground: Hsla, - /// Sidebar border color. - pub sidebar_border: Hsla, - /// Sidebar text color. - pub sidebar_foreground: Hsla, - /// Sidebar primary background color. - pub sidebar_primary: Hsla, - /// Sidebar primary text color. - pub sidebar_primary_foreground: Hsla, - /// Skeleton background color. - pub skeleton: Hsla, - /// Slider bar background color. - pub slider_bar: Hsla, - /// Slider thumb background color. - pub slider_thumb: Hsla, - /// Success background color. - pub success: Hsla, - /// Success text color. - pub success_foreground: Hsla, - /// Success hover background color. - pub success_hover: Hsla, - /// Success active background color. - pub success_active: Hsla, - /// Switch background color. - pub switch: Hsla, - /// Switch thumb background color. - pub switch_thumb: Hsla, - /// Tab background color. - pub tab: Hsla, - /// Tab active background color. - pub tab_active: Hsla, - /// Tab active text color. - pub tab_active_foreground: Hsla, - /// TabBar background color. - pub tab_bar: Hsla, - /// TabBar segmented background color. - pub tab_bar_segmented: Hsla, - /// Tab text color. - pub tab_foreground: Hsla, - /// Table background color. - pub table: Hsla, - /// Table active item background color. - pub table_active: Hsla, - /// Table active item border color. - pub table_active_border: Hsla, - /// Stripe background color for even TableRow. - pub table_even: Hsla, - /// Table head background color. - pub table_head: Hsla, - /// Table head text color. - pub table_head_foreground: Hsla, - /// Table footer background color. - pub table_foot: Hsla, - /// Table footer text color. - pub table_foot_foreground: Hsla, - /// Table item hover background color. - pub table_hover: Hsla, - /// Table row border color. - pub table_row_border: Hsla, - /// TitleBar background color, use for Window title bar. - pub title_bar: Hsla, - /// TitleBar border color. - pub title_bar_border: Hsla, - /// Background color for Tiles. - pub tiles: Hsla, - /// Warning background color. - pub warning: Hsla, - /// Warning active background color. - pub warning_active: Hsla, - /// Warning hover background color. - pub warning_hover: Hsla, - /// Warning foreground color. - pub warning_foreground: Hsla, - /// Overlay background color. - pub overlay: Hsla, - /// Window border color. - /// - /// # Platform specific: - /// - /// This is only works on Linux, other platforms we can't change the window border color. - pub window_border: Hsla, - - /// The base red color. - pub red: Hsla, - /// The base red light color. - pub red_light: Hsla, - /// The base green color. - pub green: Hsla, - /// The base green light color. - pub green_light: Hsla, - /// The base blue color. - pub blue: Hsla, - /// The base blue light color. - pub blue_light: Hsla, - /// The base yellow color. - pub yellow: Hsla, - /// The base yellow light color. - pub yellow_light: Hsla, - /// The base magenta color. - pub magenta: Hsla, - /// The base magenta light color. - pub magenta_light: Hsla, - /// The base cyan color. - pub cyan: Hsla, - /// The base cyan light color. - pub cyan_light: Hsla, -} - -impl ThemeColor { - /// Get the default light theme colors. - pub fn light() -> Arc { - DEFAULT_THEME_COLORS[&ThemeMode::Light].0.clone() - } - - /// Get the default dark theme colors. - pub fn dark() -> Arc { - DEFAULT_THEME_COLORS[&ThemeMode::Dark].0.clone() - } -} diff --git a/vendor/gpui-component/crates/ui/src/time/calendar.rs b/vendor/gpui-component/crates/ui/src/time/calendar.rs deleted file mode 100644 index 5ca8632f..00000000 --- a/vendor/gpui-component/crates/ui/src/time/calendar.rs +++ /dev/null @@ -1,1014 +0,0 @@ -use std::{borrow::Cow, rc::Rc}; - -use chrono::{Datelike, Local, NaiveDate}; -use gpui::{ - App, ClickEvent, Context, Div, ElementId, Empty, Entity, EventEmitter, FocusHandle, - InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, SharedString, Stateful, - StatefulInteractiveElement, StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px, - relative, -}; -use rust_i18n::t; - -use crate::{ - ActiveTheme, Disableable as _, IconName, Selectable, Sizable, Size, StyledExt as _, - button::{Button, ButtonVariants as _}, - h_flex, v_flex, -}; - -use super::utils::days_in_month; - -/// Events emitted by the calendar. -pub enum CalendarEvent { - /// The user selected a date. - Selected(Date), -} - -/// The date of the calendar. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Date { - Single(Option), - Range(Option, Option), -} - -impl std::fmt::Display for Date { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Single(Some(date)) => write!(f, "{}", date), - Self::Single(None) => write!(f, "nil"), - Self::Range(Some(start), Some(end)) => write!(f, "{} - {}", start, end), - Self::Range(None, None) => write!(f, "nil"), - Self::Range(Some(start), None) => write!(f, "{} - nil", start), - Self::Range(None, Some(end)) => write!(f, "nil - {}", end), - } - } -} - -impl From for Date { - fn from(date: NaiveDate) -> Self { - Self::Single(Some(date)) - } -} - -impl From<(NaiveDate, NaiveDate)> for Date { - fn from((start, end): (NaiveDate, NaiveDate)) -> Self { - Self::Range(Some(start), Some(end)) - } -} - -impl Date { - /// Check if the date is set. - pub fn is_some(&self) -> bool { - match self { - Self::Single(Some(_)) | Self::Range(Some(_), _) => true, - _ => false, - } - } - - /// Check if the date is complete. - pub fn is_complete(&self) -> bool { - match self { - Self::Range(Some(_), Some(_)) => true, - Self::Single(Some(_)) => true, - _ => false, - } - } - - /// Get the start date. - pub fn start(&self) -> Option { - match self { - Self::Single(Some(date)) => Some(*date), - Self::Range(Some(start), _) => Some(*start), - _ => None, - } - } - - /// Get the end date. - pub fn end(&self) -> Option { - match self { - Self::Range(_, Some(end)) => Some(*end), - _ => None, - } - } - - /// Return formatted date string. - pub fn format(&self, format: &str) -> Option { - match self { - Self::Single(Some(date)) => Some(date.format(format).to_string().into()), - Self::Range(Some(start), Some(end)) => { - Some(format!("{} - {}", start.format(format), end.format(format)).into()) - } - _ => None, - } - } - - fn is_active(&self, v: &NaiveDate) -> bool { - let v = *v; - match self { - Self::Single(d) => Some(v) == *d, - Self::Range(start, end) => Some(v) == *start || Some(v) == *end, - } - } - - fn is_single(&self) -> bool { - matches!(self, Self::Single(_)) - } - - fn is_in_range(&self, v: &NaiveDate) -> bool { - let v = *v; - match self { - Self::Range(start, end) => { - if let Some(start) = start { - if let Some(end) = end { - v >= *start && v <= *end - } else { - false - } - } else { - false - } - } - _ => false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ViewMode { - Day, - Month, - Year, -} - -impl ViewMode { - fn is_day(&self) -> bool { - matches!(self, Self::Day) - } - - fn is_month(&self) -> bool { - matches!(self, Self::Month) - } - - fn is_year(&self) -> bool { - matches!(self, Self::Year) - } -} - -/// Matcher to match dates before and after the interval. -pub struct IntervalMatcher { - before: Option, - after: Option, -} - -/// Matcher to match dates within the range. -pub struct RangeMatcher { - from: Option, - to: Option, -} - -/// Matcher to match dates. -pub enum Matcher { - /// Match declare days of the week. - /// - /// Matcher::DayOfWeek(vec![0, 6]) - /// Will match the days of the week that are Sunday and Saturday. - DayOfWeek(Vec), - /// Match the included days, except for those before and after the interval. - /// - /// Matcher::Interval(IntervalMatcher { - /// before: Some(NaiveDate::from_ymd(2020, 1, 2)), - /// after: Some(NaiveDate::from_ymd(2020, 1, 3)), - /// }) - /// Will match the days that are not between 2020-01-02 and 2020-01-03. - Interval(IntervalMatcher), - /// Match the days within the range. - /// - /// Matcher::Range(RangeMatcher { - /// from: Some(NaiveDate::from_ymd(2020, 1, 1)), - /// to: Some(NaiveDate::from_ymd(2020, 1, 3)), - /// }) - /// Will match the days that are between 2020-01-01 and 2020-01-03. - Range(RangeMatcher), - /// Match dates using a custom function. - /// - /// let matcher = Matcher::Custom(Box::new(|date: &NaiveDate| { - /// date.day0() < 5 - /// })); - /// Will match first 5 days of each month - Custom(Box bool + Send + Sync>), -} - -impl From> for Matcher { - fn from(days: Vec) -> Self { - Matcher::DayOfWeek(days) - } -} - -impl From for Matcher -where - F: Fn(&NaiveDate) -> bool + Send + Sync + 'static, -{ - fn from(f: F) -> Self { - Matcher::Custom(Box::new(f)) - } -} - -impl Matcher { - /// Create a new interval matcher. - pub fn interval(before: Option, after: Option) -> Self { - Matcher::Interval(IntervalMatcher { before, after }) - } - - /// Create a new range matcher. - pub fn range(from: Option, to: Option) -> Self { - Matcher::Range(RangeMatcher { from, to }) - } - - /// Create a new custom matcher. - pub fn custom(f: F) -> Self - where - F: Fn(&NaiveDate) -> bool + Send + Sync + 'static, - { - Matcher::Custom(Box::new(f)) - } - - /// Check if the date matches the matcher. - pub fn is_match(&self, date: &Date) -> bool { - match date { - Date::Single(Some(date)) => self.matched(date), - Date::Range(Some(start), Some(end)) => self.matched(start) || self.matched(end), - _ => false, - } - } - - fn matched(&self, date: &NaiveDate) -> bool { - match self { - Matcher::DayOfWeek(days) => days.contains(&date.weekday().num_days_from_sunday()), - Matcher::Interval(interval) => { - let before_check = interval.before.map_or(false, |before| date < &before); - let after_check = interval.after.map_or(false, |after| date > &after); - before_check || after_check - } - Matcher::Range(range) => { - let from_check = range.from.map_or(false, |from| date < &from); - let to_check = range.to.map_or(false, |to| date > &to); - !from_check && !to_check - } - Matcher::Custom(f) => f(date), - } - } -} - -#[derive(IntoElement)] -pub struct Calendar { - id: ElementId, - size: Size, - state: Entity, - style: StyleRefinement, - /// Number of the months view to show. - number_of_months: usize, -} - -/// Use to store the state of the calendar. -pub struct CalendarState { - focus_handle: FocusHandle, - view_mode: ViewMode, - date: Date, - current_year: i32, - current_month: u8, - years: Vec>, - year_page: i32, - today: NaiveDate, - /// Number of the months view to show. - number_of_months: usize, - pub(crate) disabled_matcher: Option>, -} - -impl CalendarState { - /// Create a new calendar state. - pub fn new(_: &mut Window, cx: &mut Context) -> Self { - let today = Local::now().naive_local().date(); - Self { - focus_handle: cx.focus_handle(), - view_mode: ViewMode::Day, - date: Date::Single(None), - current_month: today.month() as u8, - current_year: today.year(), - years: vec![], - year_page: 0, - today, - number_of_months: 1, - disabled_matcher: None, - } - .year_range((today.year() - 50, today.year() + 50)) - } - - /// Set the disabled matcher of the calendar state. - pub fn disabled_matcher(mut self, matcher: impl Into) -> Self { - self.disabled_matcher = Some(Rc::new(matcher.into())); - self - } - - /// Set the disabled matcher of the calendar. - /// - /// The disabled matcher will be used to disable the days that match the matcher. - pub fn set_disabled_matcher( - &mut self, - disabled: impl Into, - _: &mut Window, - _: &mut Context, - ) { - self.disabled_matcher = Some(Rc::new(disabled.into())); - } - - /// Set the date of the calendar. - /// - /// When you set a range date, the mode will be automatically set to `Mode::Range`. - pub fn set_date(&mut self, date: impl Into, _: &mut Window, cx: &mut Context) { - let date = date.into(); - - let invalid = self - .disabled_matcher - .as_ref() - .map_or(false, |matcher| matcher.is_match(&date)); - - if invalid { - return; - } - - self.date = date; - match self.date { - Date::Single(Some(date)) => { - self.current_month = date.month() as u8; - self.current_year = date.year(); - } - Date::Range(Some(start), _) => { - self.current_month = start.month() as u8; - self.current_year = start.year(); - } - _ => {} - } - - cx.notify() - } - - /// Get the date of the calendar. - pub fn date(&self) -> Date { - self.date - } - - /// Set number of months to show. - pub fn set_number_of_months( - &mut self, - number_of_months: usize, - _: &mut Window, - cx: &mut Context, - ) { - self.number_of_months = number_of_months; - cx.notify(); - } - - /// Set the year range of the calendar, default is 50 years before and after the current year. - /// - /// Each year page contains 20 years, so the range will be divided into chunks of 20 years is better. - pub fn year_range(mut self, range: (i32, i32)) -> Self { - self.apply_year_range(range); - self - } - - /// Set the year range of the calendar. - pub fn set_year_range(&mut self, range: (i32, i32), cx: &mut Context) { - self.apply_year_range(range); - cx.notify(); - } - - fn apply_year_range(&mut self, range: (i32, i32)) { - self.years = (range.0..range.1) - .collect::>() - .chunks(20) - .map(|chunk| chunk.to_vec()) - .collect::>(); - self.year_page = self - .years - .iter() - .position(|years| years.contains(&self.current_year)) - .unwrap_or(0) as i32; - } - - /// Get year and month by offset month. - fn offset_year_month(&self, offset_month: usize) -> (i32, u32) { - let mut month = self.current_month as i32 + offset_month as i32; - let mut year = self.current_year; - while month < 1 { - month += 12; - year -= 1; - } - while month > 12 { - month -= 12; - year += 1; - } - - (year, month as u32) - } - - /// Returns the days of the month in a 2D vector to render on calendar. - fn days(&self) -> Vec> { - (0..self.number_of_months) - .flat_map(|offset| { - days_in_month(self.current_year, self.current_month as u32 + offset as u32) - }) - .collect() - } - - fn has_prev_year_page(&self) -> bool { - self.year_page > 0 - } - - fn has_next_year_page(&self) -> bool { - self.year_page < self.years.len() as i32 - 1 - } - - fn prev_year_page(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - if !self.has_prev_year_page() { - return; - } - - self.year_page -= 1; - cx.notify() - } - - fn next_year_page(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - if !self.has_next_year_page() { - return; - } - - self.year_page += 1; - cx.notify() - } - - fn prev_month(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - self.current_month = if self.current_month == 1 { - 12 - } else { - self.current_month - 1 - }; - self.current_year = if self.current_month == 12 { - self.current_year - 1 - } else { - self.current_year - }; - cx.notify() - } - - fn next_month(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - self.current_month = if self.current_month == 12 { - 1 - } else { - self.current_month + 1 - }; - self.current_year = if self.current_month == 1 { - self.current_year + 1 - } else { - self.current_year - }; - cx.notify() - } - - fn month_name(&self, offset_month: usize) -> SharedString { - let (_, month) = self.offset_year_month(offset_month); - match month { - 1 => t!("Calendar.month.January"), - 2 => t!("Calendar.month.February"), - 3 => t!("Calendar.month.March"), - 4 => t!("Calendar.month.April"), - 5 => t!("Calendar.month.May"), - 6 => t!("Calendar.month.June"), - 7 => t!("Calendar.month.July"), - 8 => t!("Calendar.month.August"), - 9 => t!("Calendar.month.September"), - 10 => t!("Calendar.month.October"), - 11 => t!("Calendar.month.November"), - 12 => t!("Calendar.month.December"), - _ => Cow::Borrowed(""), - } - .into() - } - - fn year_name(&self, offset_month: usize) -> SharedString { - let (year, _) = self.offset_year_month(offset_month); - year.to_string().into() - } - - fn set_view_mode(&mut self, mode: ViewMode, _: &mut Window, cx: &mut Context) { - self.view_mode = mode; - cx.notify(); - } - - fn months(&self) -> Vec { - [ - t!("Calendar.month.January"), - t!("Calendar.month.February"), - t!("Calendar.month.March"), - t!("Calendar.month.April"), - t!("Calendar.month.May"), - t!("Calendar.month.June"), - t!("Calendar.month.July"), - t!("Calendar.month.August"), - t!("Calendar.month.September"), - t!("Calendar.month.October"), - t!("Calendar.month.November"), - t!("Calendar.month.December"), - ] - .iter() - .map(|s| s.clone().into()) - .collect() - } -} - -impl Render for CalendarState { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } -} - -impl Calendar { - /// Create a new calendar element with [`CalendarState`]. - pub fn new(state: &Entity) -> Self { - Self { - id: ("calendar", state.entity_id()).into(), - size: Size::default(), - state: state.clone(), - style: StyleRefinement::default(), - number_of_months: 1, - } - } - - /// Set number of months to show, default is 1. - pub fn number_of_months(mut self, number_of_months: usize) -> Self { - self.number_of_months = number_of_months; - self - } - - fn render_day( - &self, - d: &NaiveDate, - offset_month: usize, - window: &mut Window, - cx: &mut App, - ) -> Stateful
{ - let state = self.state.read(cx); - let (_, month) = state.offset_year_month(offset_month); - let day = d.day(); - let is_current_month = d.month() == month; - let is_active = state.date.is_active(d); - let is_in_range = state.date.is_in_range(d); - - let date = *d; - let is_today = *d == state.today; - let disabled = state - .disabled_matcher - .as_ref() - .map_or(false, |disabled| disabled.matched(&date)); - - let date_id: SharedString = format!("{}_{}", date.format("%Y-%m-%d"), offset_month).into(); - - self.item_button( - date_id.clone(), - day.to_string(), - is_active, - is_in_range, - !is_current_month || disabled, - disabled, - window, - cx, - ) - .when(is_today && !is_active, |this| { - this.border_1().border_color(cx.theme().border) - }) // Add border for today - .when(!disabled, |this| { - this.on_click(window.listener_for( - &self.state, - move |view, _: &ClickEvent, window, cx| { - if view.date.is_single() { - view.set_date(date, window, cx); - cx.emit(CalendarEvent::Selected(view.date())); - } else { - let start = view.date.start(); - let end = view.date.end(); - - if start.is_none() && end.is_none() { - view.set_date(Date::Range(Some(date), None), window, cx); - } else if start.is_some() && end.is_none() { - if date < start.unwrap() { - view.set_date(Date::Range(Some(date), None), window, cx); - } else { - view.set_date( - Date::Range(Some(start.unwrap()), Some(date)), - window, - cx, - ); - } - } else { - view.set_date(Date::Range(Some(date), None), window, cx); - } - - if view.date.is_complete() { - cx.emit(CalendarEvent::Selected(view.date())); - } - } - }, - )) - }) - } - - fn render_header(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let state = self.state.read(cx); - let current_year = state.current_year; - let view_mode = state.view_mode; - let disabled = view_mode.is_month(); - let multiple_months = self.number_of_months > 1; - let icon_size = match self.size { - Size::Small => Size::Small, - Size::Large => Size::Medium, - _ => Size::Medium, - }; - - h_flex() - .gap_0p5() - .justify_between() - .items_center() - .child( - Button::new("prev") - .icon(IconName::ArrowLeft) - .tab_stop(false) - .ghost() - .disabled(disabled) - .with_size(icon_size) - .when(view_mode.is_day(), |this| { - this.on_click(window.listener_for(&self.state, CalendarState::prev_month)) - }) - .when(view_mode.is_year(), |this| { - this.when(!state.has_prev_year_page(), |this| this.disabled(true)) - .on_click( - window.listener_for(&self.state, CalendarState::prev_year_page), - ) - }), - ) - .when(!multiple_months, |this| { - this.child( - h_flex() - .justify_center() - .gap_3() - .child( - Button::new("month") - .ghost() - .label(state.month_name(0)) - .compact() - .tab_stop(false) - .with_size(self.size) - .selected(view_mode.is_month()) - .on_click(window.listener_for( - &self.state, - move |view, _, window, cx| { - if view_mode.is_month() { - view.set_view_mode(ViewMode::Day, window, cx); - } else { - view.set_view_mode(ViewMode::Month, window, cx); - } - cx.notify(); - }, - )), - ) - .child( - Button::new("year") - .ghost() - .label(current_year.to_string()) - .compact() - .tab_stop(false) - .with_size(self.size) - .selected(view_mode.is_year()) - .on_click(window.listener_for( - &self.state, - |view, _, window, cx| { - if view.view_mode.is_year() { - view.set_view_mode(ViewMode::Day, window, cx); - } else { - view.set_view_mode(ViewMode::Year, window, cx); - } - cx.notify(); - }, - )), - ), - ) - }) - .when(multiple_months, |this| { - this.child(h_flex().flex_1().justify_around().children( - (0..self.number_of_months).map(|n| { - h_flex() - .justify_center() - .map(|this| match self.size { - Size::Small => this.gap_2(), - Size::Large => this.gap_4(), - _ => this.gap_3(), - }) - .child(state.month_name(n)) - .child(state.year_name(n)) - }), - )) - }) - .child( - Button::new("next") - .icon(IconName::ArrowRight) - .ghost() - .tab_stop(false) - .disabled(disabled) - .with_size(icon_size) - .when(view_mode.is_day(), |this| { - this.on_click(window.listener_for(&self.state, CalendarState::next_month)) - }) - .when(view_mode.is_year(), |this| { - this.when(!state.has_next_year_page(), |this| this.disabled(true)) - .on_click( - window.listener_for(&self.state, CalendarState::next_year_page), - ) - }), - ) - } - - #[allow(clippy::too_many_arguments)] - fn item_button( - &self, - id: impl Into, - label: impl Into, - active: bool, - secondary_active: bool, - muted: bool, - disabled: bool, - _: &mut Window, - cx: &mut App, - ) -> Stateful
{ - h_flex() - .id(id.into()) - .map(|this| match self.size { - Size::Small => this.size_7().rounded(cx.theme().radius / 2.), - Size::Large => this.size_10().rounded(cx.theme().radius * 2.), - _ => this.size_9().rounded(cx.theme().radius), - }) - .justify_center() - .when(muted, |this| { - this.text_color(if disabled { - cx.theme().muted_foreground.opacity(0.3) - } else { - cx.theme().muted_foreground - }) - }) - .when(secondary_active, |this| { - this.bg(if muted { - cx.theme().accent.opacity(0.5) - } else { - cx.theme().accent - }) - .text_color(cx.theme().accent_foreground) - }) - .when(!active && !disabled, |this| { - this.hover(|this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - }) - }) - .when(active, |this| { - this.bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - }) - .child(label.into()) - } - - fn render_days(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let state = self.state.read(cx); - let weeks = [ - t!("Calendar.week.0"), - t!("Calendar.week.1"), - t!("Calendar.week.2"), - t!("Calendar.week.3"), - t!("Calendar.week.4"), - t!("Calendar.week.5"), - t!("Calendar.week.6"), - ]; - - h_flex() - .map(|this| match self.size { - Size::Small => this.gap_3().text_sm(), - Size::Large => this.gap_5().text_base(), - _ => this.gap_4().text_sm(), - }) - .justify_between() - .children( - state - .days() - .chunks(5) - .enumerate() - .map(|(offset_month, days)| { - v_flex() - .gap_0p5() - .child( - h_flex().gap_0p5().justify_between().children( - weeks - .iter() - .map(|week| self.render_week(week.clone(), window, cx)), - ), - ) - .children(days.iter().map(|week| { - h_flex().gap_0p5().justify_between().children( - week.iter() - .map(|d| self.render_day(d, offset_month, window, cx)), - ) - })) - }), - ) - } - - fn render_week(&self, week: impl Into, _: &mut Window, cx: &mut App) -> Div { - h_flex() - .map(|this| match self.size { - Size::Small => this.size_7().rounded(cx.theme().radius / 2.0), - Size::Large => this.size_10().rounded(cx.theme().radius), - _ => this.size_9().rounded(cx.theme().radius), - }) - .justify_center() - .text_color(cx.theme().muted_foreground) - .text_sm() - .child(week.into()) - } - - fn render_months(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let state = self.state.read(cx); - let months = state.months(); - let current_month = state.current_month; - - h_flex() - .mt_3() - .gap_0p5() - .gap_y_3() - .map(|this| match self.size { - Size::Small => this.mt_2().gap_y_2().w(px(208.)), - Size::Large => this.mt_4().gap_y_4().w(px(292.)), - _ => this.mt_3().gap_y_3().w(px(264.)), - }) - .justify_between() - .flex_wrap() - .children( - months - .iter() - .enumerate() - .map(|(ix, month)| { - let active = (ix + 1) as u8 == current_month; - - self.item_button( - ix, - month.to_string(), - active, - false, - false, - false, - window, - cx, - ) - .w(relative(0.3)) - .text_sm() - .on_click(window.listener_for( - &self.state, - move |view, _, window, cx| { - view.current_month = (ix + 1) as u8; - view.set_view_mode(ViewMode::Day, window, cx); - cx.notify(); - }, - )) - }) - .collect::>(), - ) - } - - fn render_years(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let state = self.state.read(cx); - let current_year = state.current_year; - let current_page_years = &self.state.read(cx).years[state.year_page as usize].clone(); - - h_flex() - .id("years") - .gap_0p5() - .map(|this| match self.size { - Size::Small => this.mt_2().gap_y_2().w(px(208.)), - Size::Large => this.mt_4().gap_y_4().w(px(292.)), - _ => this.mt_3().gap_y_3().w(px(264.)), - }) - .justify_between() - .flex_wrap() - .children( - current_page_years - .iter() - .enumerate() - .map(|(ix, year)| { - let year = *year; - let active = year == current_year; - - self.item_button( - ix, - year.to_string(), - active, - false, - false, - false, - window, - cx, - ) - .w(relative(0.2)) - .on_click(window.listener_for( - &self.state, - move |view, _, window, cx| { - view.current_year = year; - view.set_view_mode(ViewMode::Day, window, cx); - cx.notify(); - }, - )) - }) - .collect::>(), - ) - } -} - -impl Sizable for Calendar { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl Styled for Calendar { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl EventEmitter for CalendarState {} -impl RenderOnce for Calendar { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let view_mode = self.state.read(cx).view_mode; - let number_of_months = self.number_of_months; - self.state.update(cx, |state, _| { - state.number_of_months = number_of_months; - }); - - v_flex() - .id(self.id.clone()) - .track_focus(&self.state.read(cx).focus_handle) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius_lg) - .p_3() - .gap_0p5() - .refine_style(&self.style) - .child(self.render_header(window, cx)) - .child( - v_flex() - .when(view_mode.is_day(), |this| { - this.child(self.render_days(window, cx)) - }) - .when(view_mode.is_month(), |this| { - this.child(self.render_months(window, cx)) - }) - .when(view_mode.is_year(), |this| { - this.child(self.render_years(window, cx)) - }), - ) - } -} - -#[cfg(test)] -mod tests { - use chrono::NaiveDate; - - use super::Date; - - #[test] - fn test_date_to_string() { - let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); - assert_eq!(date.to_string(), "2024-08-03"); - - let date = Date::Single(None); - assert_eq!(date.to_string(), "nil"); - - let date = Date::Range( - Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), - Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), - ); - assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); - - let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); - assert_eq!(date.to_string(), "2024-08-03 - nil"); - - let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); - assert_eq!(date.to_string(), "nil - 2024-08-05"); - - let date = Date::Range(None, None); - assert_eq!(date.to_string(), "nil"); - } -} diff --git a/vendor/gpui-component/crates/ui/src/time/date_picker.rs b/vendor/gpui-component/crates/ui/src/time/date_picker.rs deleted file mode 100644 index 5c2dce48..00000000 --- a/vendor/gpui-component/crates/ui/src/time/date_picker.rs +++ /dev/null @@ -1,516 +0,0 @@ -use std::rc::Rc; - -use chrono::NaiveDate; -use gpui::{ - App, AppContext, ClickEvent, Context, ElementId, Empty, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _, - Render, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, - Subscription, Window, anchored, deferred, div, prelude::FluentBuilder as _, px, -}; -use rust_i18n::t; - -use crate::{ - ActiveTheme, Disableable, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt as _, - actions::{Cancel, Confirm}, - button::{Button, ButtonVariants as _}, - h_flex, - input::{Delete, clear_button, input_style}, - v_flex, -}; - -use super::calendar::{Calendar, CalendarEvent, CalendarState, Date, Matcher}; - -const CONTEXT: &'static str = "DatePicker"; -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), - KeyBinding::new("escape", Cancel, Some(CONTEXT)), - KeyBinding::new("delete", Delete, Some(CONTEXT)), - KeyBinding::new("backspace", Delete, Some(CONTEXT)), - ]) -} - -/// Events emitted by the DatePicker. -#[derive(Clone)] -pub enum DatePickerEvent { - Change(Date), -} - -/// Preset value for DateRangePreset. -#[derive(Clone)] -pub enum DateRangePresetValue { - Single(NaiveDate), - Range(NaiveDate, NaiveDate), -} - -/// Preset for date range selection. -#[derive(Clone)] -pub struct DateRangePreset { - label: SharedString, - value: DateRangePresetValue, -} - -impl DateRangePreset { - /// Creates a new DateRangePreset with a date. - pub fn single(label: impl Into, date: NaiveDate) -> Self { - DateRangePreset { - label: label.into(), - value: DateRangePresetValue::Single(date), - } - } - /// Creates a new DateRangePreset with a range of dates. - pub fn range(label: impl Into, start: NaiveDate, end: NaiveDate) -> Self { - DateRangePreset { - label: label.into(), - value: DateRangePresetValue::Range(start, end), - } - } -} - -/// Use to store the state of the date picker. -pub struct DatePickerState { - focus_handle: FocusHandle, - date: Date, - open: bool, - calendar: Entity, - date_format: SharedString, - number_of_months: usize, - disabled_matcher: Option>, - _subscriptions: Vec, -} - -impl Focusable for DatePickerState { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} -impl EventEmitter for DatePickerState {} - -impl DatePickerState { - /// Create a date state. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_range(false, window, cx) - } - - /// Create a date state with range mode. - pub fn range(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_range(true, window, cx) - } - - fn new_with_range(is_range: bool, window: &mut Window, cx: &mut Context) -> Self { - let date = if is_range { - Date::Range(None, None) - } else { - Date::Single(None) - }; - - let calendar = cx.new(|cx| { - let mut this = CalendarState::new(window, cx); - this.set_date(date, window, cx); - this - }); - - let _subscriptions = vec![cx.subscribe_in( - &calendar, - window, - |this, _, ev: &CalendarEvent, window, cx| match ev { - CalendarEvent::Selected(date) => { - this.update_date(*date, true, window, cx); - this.focus_handle.focus(window, cx); - } - }, - )]; - - Self { - focus_handle: cx.focus_handle(), - date, - calendar, - open: false, - date_format: "%Y/%m/%d".into(), - number_of_months: 1, - disabled_matcher: None, - _subscriptions, - } - } - - /// Set the date format of the date picker to display in Input, default: "%Y/%m/%d". - pub fn date_format(mut self, format: impl Into) -> Self { - self.date_format = format.into(); - self - } - - /// Set the number of months calendar view to display, default is 1. - pub fn number_of_months(mut self, number_of_months: usize) -> Self { - self.number_of_months = number_of_months; - self - } - - /// Get the date of the date picker. - pub fn date(&self) -> Date { - self.date - } - - /// Set the date of the date picker. - pub fn set_date(&mut self, date: impl Into, window: &mut Window, cx: &mut Context) { - self.update_date(date.into(), false, window, cx); - } - - /// Set the disabled match for the calendar. - pub fn disabled_matcher(mut self, disabled: impl Into) -> Self { - self.disabled_matcher = Some(Rc::new(disabled.into())); - self - } - - /// Set the year range for the internal calendar. - /// - /// Default is 50 years before and after the current year. - /// `range` uses a half-open interval `(start, end)` where `end` is exclusive. - pub fn set_year_range(&mut self, range: (i32, i32), cx: &mut Context) { - self.calendar.update(cx, |state, cx| { - state.set_year_range(range, cx); - }); - } - - fn update_date(&mut self, date: Date, emit: bool, window: &mut Window, cx: &mut Context) { - self.date = date; - self.calendar.update(cx, |view, cx| { - view.set_date(date, window, cx); - }); - self.open = false; - if emit { - cx.emit(DatePickerEvent::Change(date)); - } - cx.notify(); - } - - /// Set the disabled matcher of the date picker. - fn set_canlendar_disabled_matcher(&mut self, _: &mut Window, cx: &mut Context) { - let matcher = self.disabled_matcher.clone(); - self.calendar.update(cx, |state, _| { - state.disabled_matcher = matcher; - }); - } - - fn on_escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if !self.open { - cx.propagate(); - } - - self.focus_back_if_need(window, cx); - self.open = false; - - cx.notify(); - } - - fn on_enter(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { - if !self.open { - self.open = true; - cx.notify(); - } - } - - fn on_delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - self.clean(&ClickEvent::default(), window, cx); - } - - // To focus the Picker Input, if current focus in is on the container. - // - // This is because mouse down out the Calendar, GPUI will move focus to the container. - // So we need to move focus back to the Picker Input. - // - // But if mouse down target is some other focusable element (e.g.: [`crate::Input`]), we should not move focus. - fn focus_back_if_need(&mut self, window: &mut Window, cx: &mut Context) { - if !self.open { - return; - } - - if let Some(focused) = window.focused(cx) { - if focused.contains(&self.focus_handle, window) { - self.focus_handle.focus(window, cx); - } - } - } - - fn clean(&mut self, _: &gpui::ClickEvent, window: &mut Window, cx: &mut Context) { - cx.stop_propagation(); - match self.date { - Date::Single(_) => { - self.update_date(Date::Single(None), true, window, cx); - } - Date::Range(_, _) => { - self.update_date(Date::Range(None, None), true, window, cx); - } - } - } - - fn toggle_calendar(&mut self, _: &gpui::ClickEvent, _: &mut Window, cx: &mut Context) { - self.open = !self.open; - cx.notify(); - } - - fn select_preset( - &mut self, - preset: &DateRangePreset, - window: &mut Window, - cx: &mut Context, - ) { - match preset.value { - DateRangePresetValue::Single(single) => { - self.update_date(Date::Single(Some(single)), true, window, cx) - } - DateRangePresetValue::Range(start, end) => { - self.update_date(Date::Range(Some(start), Some(end)), true, window, cx) - } - } - } -} - -/// A DatePicker element. -#[derive(IntoElement)] -pub struct DatePicker { - id: ElementId, - style: StyleRefinement, - state: Entity, - cleanable: bool, - placeholder: Option, - size: Size, - number_of_months: usize, - presets: Option>, - appearance: bool, - disabled: bool, -} - -impl Sizable for DatePicker { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} -impl Focusable for DatePicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.state.focus_handle(cx) - } -} - -impl Styled for DatePicker { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl Disableable for DatePicker { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Render for DatePickerState { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl gpui::IntoElement { - Empty - } -} - -impl DatePicker { - /// Create a new DatePicker with the given [`DatePickerState`]. - pub fn new(state: &Entity) -> Self { - Self { - id: ("date-picker", state.entity_id()).into(), - state: state.clone(), - cleanable: false, - placeholder: None, - size: Size::default(), - style: StyleRefinement::default(), - number_of_months: 2, - presets: None, - appearance: true, - disabled: false, - } - } - - /// Set the placeholder of the date picker, default: "". - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = Some(placeholder.into()); - self - } - - /// Set whether to show the clear button when the input field is not empty, default is false. - pub fn cleanable(mut self, cleanable: bool) -> Self { - self.cleanable = cleanable; - self - } - - /// Set preset ranges for the date picker. - pub fn presets(mut self, presets: Vec) -> Self { - self.presets = Some(presets); - self - } - - /// Set number of months to display in the calendar, default is 2. - pub fn number_of_months(mut self, number_of_months: usize) -> Self { - self.number_of_months = number_of_months; - self - } - - /// Set appearance of the date picker, if false, the date picker will be in a minimal style. - pub fn appearance(mut self, appearance: bool) -> Self { - self.appearance = appearance; - self - } -} - -impl RenderOnce for DatePicker { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - self.state.update(cx, |state, cx| { - state.set_canlendar_disabled_matcher(window, cx); - }); - - // This for keep focus border style, when click on the popup. - let is_focused = self.focus_handle(cx).contains_focused(window, cx); - let state = self.state.read(cx); - let show_clean = self.cleanable && state.date.is_some(); - let placeholder = self - .placeholder - .clone() - .unwrap_or_else(|| t!("DatePicker.placeholder").into()); - let display_title = state - .date - .format(&state.date_format) - .unwrap_or(placeholder.clone()); - - let (bg, fg) = input_style(self.disabled, cx); - - div() - .id(self.id.clone()) - .key_context(CONTEXT) - .track_focus(&self.focus_handle(cx).tab_stop(true)) - .on_action(window.listener_for(&self.state, DatePickerState::on_enter)) - .on_action(window.listener_for(&self.state, DatePickerState::on_delete)) - .when(state.open, |this| { - this.on_action(window.listener_for(&self.state, DatePickerState::on_escape)) - }) - .flex_none() - .w_full() - .relative() - .input_text_size(self.size) - .refine_style(&self.style) - .child( - div() - .id("date-picker-input") - .relative() - .flex() - .items_center() - .justify_between() - .when(self.appearance, |this| { - this.bg(bg) - .text_color(fg) - .when(self.disabled, |this| this.opacity(0.5)) - .border_1() - .border_color(cx.theme().input) - .rounded(cx.theme().radius) - .when(cx.theme().shadow, |this| this.shadow_xs()) - .when(is_focused, |this| this.focused_border(cx)) - }) - .overflow_hidden() - .input_text_size(self.size) - .input_size(self.size) - .when(!state.open && !self.disabled, |this| { - this.on_click( - window.listener_for(&self.state, DatePickerState::toggle_calendar), - ) - }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_1() - .child( - div() - .w_full() - .overflow_hidden() - .when(!state.date.is_some(), |this| { - this.text_color(cx.theme().muted_foreground) - }) - .child(display_title), - ) - .when(!self.disabled, |this| { - this.when(show_clean, |this| { - this.child(clear_button(cx).on_click( - window.listener_for(&self.state, DatePickerState::clean), - )) - }) - .when(!show_clean, |this| { - this.child( - Icon::new(IconName::Calendar) - .xsmall() - .text_color(cx.theme().muted_foreground), - ) - }) - }), - ), - ) - .when(state.open, |this| { - this.child( - deferred( - anchored().snap_to_window_with_margin(px(8.)).child( - div() - .occlude() - .mt_1p5() - .p_3() - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded((cx.theme().radius * 2.).min(px(8.))) - .bg(cx.theme().popover) - .text_color(cx.theme().popover_foreground) - .on_mouse_up_out( - MouseButton::Left, - window.listener_for(&self.state, |view, _, window, cx| { - view.on_escape(&Cancel, window, cx); - }), - ) - .child( - h_flex() - .gap_3() - .h_full() - .items_start() - .when_some(self.presets.clone(), |this, presets| { - this.child( - v_flex().my_1().gap_2().justify_end().children( - presets.into_iter().enumerate().map( - |(i, preset)| { - Button::new(("preset", i)) - .small() - .ghost() - .tab_stop(false) - .label(preset.label.clone()) - .on_click(window.listener_for( - &self.state, - move |this, _, window, cx| { - this.select_preset( - &preset, window, cx, - ); - }, - )) - }, - ), - ), - ) - }) - .child( - Calendar::new(&state.calendar) - .number_of_months(self.number_of_months) - .border_0() - .rounded_none() - .p_0() - .with_size(self.size), - ), - ), - ), - ) - .with_priority(2), - ) - }) - } -} diff --git a/vendor/gpui-component/crates/ui/src/time/mod.rs b/vendor/gpui-component/crates/ui/src/time/mod.rs deleted file mode 100644 index 2e8b2b15..00000000 --- a/vendor/gpui-component/crates/ui/src/time/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod calendar; -pub mod date_picker; -mod utils; diff --git a/vendor/gpui-component/crates/ui/src/time/utils.rs b/vendor/gpui-component/crates/ui/src/time/utils.rs deleted file mode 100644 index b9f47a61..00000000 --- a/vendor/gpui-component/crates/ui/src/time/utils.rs +++ /dev/null @@ -1,181 +0,0 @@ -use chrono::{Datelike, Duration, NaiveDate}; - -trait NaiveDateExt { - fn days_in_month(&self) -> i32; - fn is_leap_year(&self) -> bool; -} - -impl NaiveDateExt for chrono::NaiveDate { - fn days_in_month(&self) -> i32 { - let month = self.month(); - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if self.is_leap_year() { - 29 - } else { - 28 - } - } - _ => panic!("Invalid month: {}", month), - } - } - - fn is_leap_year(&self) -> bool { - let year = self.year(); - return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - } -} - -pub(crate) fn days_in_month(year: i32, month: u32) -> Vec> { - let mut year = year; - let mut month = month; - if month > 12 { - year += 1; - month = 1; - } - if month < 1 { - year -= 1; - month = 12; - } - - let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); - let num_days = date.days_in_month(); - let start_weekday = date.weekday().num_days_from_sunday(); - - // Get the days in the month, 2023-02 will returns - // "29|30|31| 1| 2| 3| 4", - // " 5| 6| 7| 8| 9|10|11", - // "12|13|14|15|16|17|18", - // "19|20|21|22|23|24|25", - // "26|27|28| 1| 2| 3| 4", - let mut days = vec![]; - for n in 0..5 { - let mut week_days = vec![]; - for weekday in 0..7 { - let (mut y, mut m) = (year, month); - - // If the day is less than the start weekday, we need to go back to the previous month. - if n == 0 && weekday < start_weekday { - m = if m == 1 { 12 } else { m - 1 }; - y = if m == 1 { year - 1 } else { y }; - } - - // If start_weekday is 3, and n is 0 and weekday is 3, then day is 1. - // If start_weekday is 3, and n is 1 and weekday is 4, then day is 9. - let day = n * 7 + weekday as i32 - start_weekday as i32; - - // If the day is greater than the number of days in the month, we need to go to the next month. - if day > num_days { - m = if m == 12 { 1 } else { m + 1 }; - y = if m == 1 { year + 1 } else { y }; - } - - #[allow(clippy::expect_fun_call)] - let date = date - .checked_add_signed(Duration::days(day as i64)) - .expect(&format!("invalid date {}-{} days {}", y, m, day)); - week_days.push(date); - } - - days.push(week_days); - } - - days -} - -#[cfg(test)] -mod tests { - use chrono::{Datelike, NaiveDate}; - - use super::{NaiveDateExt, days_in_month}; - - #[test] - fn test_days_in_month() { - assert_eq!( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), - 29 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), - 28 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), - 31 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), - 30 - ); - } - - #[test] - fn test_days() { - #[track_caller] - fn assert_case(date: NaiveDate, expected: Vec<&str>) { - let out = days_in_month(date.year(), date.month()) - .iter() - .map(|week| { - week.iter() - .map(|d| { - if d.year() == date.year() && d.month() == date.month() { - format!("{:2}", d.day()) - } else if d.year() == date.year() { - format!("{}-{}", d.month(), d.day()) - } else { - format!("{}-{}-{}", d.year(), d.month(), d.day()) - } - }) - .collect::>() - .join("|") - }) - .collect::>(); - - assert_eq!(out, expected); - } - - assert_case( - NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), - vec![ - "7-28|7-29|7-30|7-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|30|31", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - vec![ - "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|29|30|31|2-1", - ], - ); - - assert_case( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), - vec![ - "1-28|1-29|1-30|1-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|3-1|3-2", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), - vec![ - "1-29|1-30|1-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|3-1|3-2|3-3|3-4", - ], - ); - } -} diff --git a/vendor/gpui-component/crates/ui/src/title_bar.rs b/vendor/gpui-component/crates/ui/src/title_bar.rs deleted file mode 100644 index ab1afc4c..00000000 --- a/vendor/gpui-component/crates/ui/src/title_bar.rs +++ /dev/null @@ -1,335 +0,0 @@ -use std::rc::Rc; - -use crate::{ - ActiveTheme, Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex, -}; -use gpui::{ - AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, - StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, - prelude::FluentBuilder as _, px, -}; -use smallvec::SmallVec; - -pub const TITLE_BAR_HEIGHT: Pixels = px(34.); -#[cfg(target_os = "macos")] -const TITLE_BAR_LEFT_PADDING: Pixels = px(80.); -#[cfg(not(target_os = "macos"))] -const TITLE_BAR_LEFT_PADDING: Pixels = px(12.); - -/// TitleBar used to customize the appearance of the title bar. -/// -/// We can put some elements inside the title bar. -#[derive(IntoElement)] -pub struct TitleBar { - style: StyleRefinement, - children: SmallVec<[AnyElement; 1]>, - on_close_window: Option>>, -} - -impl TitleBar { - /// Create a new TitleBar. - pub fn new() -> Self { - Self { - style: StyleRefinement::default(), - children: SmallVec::new(), - on_close_window: None, - } - } - - /// Returns the default title bar options for compatible with the [`crate::TitleBar`]. - pub fn title_bar_options() -> TitlebarOptions { - TitlebarOptions { - title: None, - appears_transparent: true, - traffic_light_position: Some(gpui::point(px(9.0), px(9.0))), - } - } - - /// Add custom for close window event, default is None, then click X button will call `window.remove_window()`. - /// Linux only, this will do nothing on other platforms. - pub fn on_close_window( - mut self, - f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - if cfg!(target_os = "linux") { - self.on_close_window = Some(Rc::new(Box::new(f))); - } - self - } -} - -// The Windows control buttons have a fixed width of 35px. -// -// We don't need implementation the click event for the control buttons. -// If user clicked in the bounds, the window event will be triggered. -#[derive(IntoElement, Clone)] -enum ControlIcon { - Minimize, - Restore, - Maximize, - Close { - on_close_window: Option>>, - }, -} - -impl ControlIcon { - fn minimize() -> Self { - Self::Minimize - } - - fn restore() -> Self { - Self::Restore - } - - fn maximize() -> Self { - Self::Maximize - } - - fn close(on_close_window: Option>>) -> Self { - Self::Close { on_close_window } - } - - fn id(&self) -> &'static str { - match self { - Self::Minimize => "minimize", - Self::Restore => "restore", - Self::Maximize => "maximize", - Self::Close { .. } => "close", - } - } - - fn icon(&self) -> IconName { - match self { - Self::Minimize => IconName::WindowMinimize, - Self::Restore => IconName::WindowRestore, - Self::Maximize => IconName::WindowMaximize, - Self::Close { .. } => IconName::WindowClose, - } - } - - fn window_control_area(&self) -> WindowControlArea { - match self { - Self::Minimize => WindowControlArea::Min, - Self::Restore | Self::Maximize => WindowControlArea::Max, - Self::Close { .. } => WindowControlArea::Close, - } - } - - fn is_close(&self) -> bool { - matches!(self, Self::Close { .. }) - } - - #[inline] - fn hover_fg(&self, cx: &App) -> Hsla { - if self.is_close() { - cx.theme().danger_foreground - } else { - cx.theme().secondary_foreground - } - } - - #[inline] - fn hover_bg(&self, cx: &App) -> Hsla { - if self.is_close() { - cx.theme().danger - } else { - cx.theme().secondary_hover - } - } - - #[inline] - fn active_bg(&self, cx: &mut App) -> Hsla { - if self.is_close() { - cx.theme().danger_active - } else { - cx.theme().secondary_active - } - } -} - -impl RenderOnce for ControlIcon { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let is_linux = cfg!(target_os = "linux"); - let is_windows = cfg!(target_os = "windows"); - let hover_fg = self.hover_fg(cx); - let hover_bg = self.hover_bg(cx); - let active_bg = self.active_bg(cx); - let icon = self.clone(); - let on_close_window = match &self { - ControlIcon::Close { on_close_window } => on_close_window.clone(), - _ => None, - }; - - div() - .id(self.id()) - .flex() - .w(TITLE_BAR_HEIGHT) - .h_full() - .flex_shrink_0() - .justify_center() - .content_center() - .items_center() - .text_color(cx.theme().foreground) - .hover(|style| style.bg(hover_bg).text_color(hover_fg)) - .active(|style| style.bg(active_bg).text_color(hover_fg)) - .when(is_windows, |this| { - this.window_control_area(self.window_control_area()) - }) - .when(is_linux, |this| { - this.on_mouse_down(MouseButton::Left, move |_, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - }) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - match icon { - Self::Minimize => window.minimize_window(), - Self::Restore | Self::Maximize => window.zoom_window(), - Self::Close { .. } => { - if let Some(f) = on_close_window.clone() { - f(&ClickEvent::default(), window, cx); - } else { - window.remove_window(); - } - } - } - }) - }) - .child(Icon::new(self.icon()).small()) - } -} - -#[derive(IntoElement)] -struct WindowControls { - on_close_window: Option>>, -} - -impl RenderOnce for WindowControls { - fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { - if cfg!(target_os = "macos") || cfg!(target_family = "wasm") { - return div().id("window-controls"); - } - - h_flex() - .id("window-controls") - .items_center() - .flex_shrink_0() - .h_full() - .child(ControlIcon::minimize()) - .child(if window.is_maximized() { - ControlIcon::restore() - } else { - ControlIcon::maximize() - }) - .child(ControlIcon::close(self.on_close_window)) - } -} - -impl Styled for TitleBar { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} - -impl ParentElement for TitleBar { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -struct TitleBarState { - should_move: bool, -} - -// TODO: Remove this when GPUI has released v0.2.3 -impl Render for TitleBarState { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - } -} - -impl RenderOnce for TitleBar { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_client_decorated = matches!(window.window_decorations(), Decorations::Client { .. }); - let is_web = cfg!(target_family = "wasm"); - let is_linux = cfg!(target_os = "linux"); - let is_macos = cfg!(target_os = "macos"); - - let state = window.use_state(cx, |_, _| TitleBarState { should_move: false }); - - div().flex_shrink_0().child( - div() - .id("title-bar") - .flex() - .flex_row() - .items_center() - .justify_between() - .h(TITLE_BAR_HEIGHT) - .pl(TITLE_BAR_LEFT_PADDING) - .border_b_1() - .border_color(cx.theme().title_bar_border) - .bg(cx.theme().title_bar) - .refine_style(&self.style) - .when(is_linux, |this| { - this.on_double_click(|_, window, _| window.zoom_window()) - }) - .when(is_macos, |this| { - this.on_double_click(|_, window, _| window.titlebar_double_click()) - }) - .on_mouse_down_out(window.listener_for(&state, |state, _, _, _| { - state.should_move = false; - })) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&state, |state, _, _, _| { - state.should_move = true; - }), - ) - .on_mouse_up( - MouseButton::Left, - window.listener_for(&state, |state, _, _, _| { - state.should_move = false; - }), - ) - .on_mouse_move(window.listener_for(&state, |state, _, window, _| { - if state.should_move { - state.should_move = false; - window.start_window_move(); - } - })) - .child( - h_flex() - .id("bar") - .h_full() - .justify_between() - .flex_shrink_0() - .flex_1() - .when(!is_web, |this| { - this.window_control_area(WindowControlArea::Drag) - .when(window.is_fullscreen(), |this| this.pl_3()) - .when(is_linux && is_client_decorated, |this| { - this.child( - div() - .top_0() - .left_0() - .absolute() - .size_full() - .h_full() - .on_mouse_down( - MouseButton::Right, - move |ev, window, _| { - window.show_window_menu(ev.position) - }, - ), - ) - }) - }) - .children(self.children), - ) - .child(WindowControls { - on_close_window: self.on_close_window, - }), - ) - } -} diff --git a/vendor/gpui-component/crates/ui/src/tooltip.rs b/vendor/gpui-component/crates/ui/src/tooltip.rs deleted file mode 100644 index 96715120..00000000 --- a/vendor/gpui-component/crates/ui/src/tooltip.rs +++ /dev/null @@ -1,734 +0,0 @@ -use std::{cell::Cell, rc::Rc, time::Duration}; - -use gpui::{ - Action, AnyElement, AnyView, App, AppContext, Bounds, Context, Display, Element, ElementId, - GlobalElementId, Half, InspectorElementId, IntoElement, LayoutId, MouseButton, ParentElement, - Pixels, Point, Position, Render, SharedString, Size, StatefulInteractiveElement, Style, - StyleRefinement, Styled, Task, Window, deferred, div, point, prelude::FluentBuilder, px, -}; - -use crate::{ - ActiveTheme, StyledExt, - animation::{Transition, ease_in_out_cubic, ease_out_cubic}, - h_flex, - kbd::Kbd, - root::Root, - text::Text, -}; - -pub(crate) fn init(_cx: &mut App) { - // No app-level init needed — TooltipOverlay is per-window via Root. -} - -// ── Tooltip view (unchanged API) ──────────────────────────────────────────── - -enum TooltipContext { - Text(Text), - Element(Box AnyElement>), -} - -/// A Tooltip element that can display text or custom content, -/// with optional key binding information. -pub struct Tooltip { - style: StyleRefinement, - content: TooltipContext, - key_binding: Option, - action: Option<(Box, Option)>, -} - -impl Tooltip { - /// Create a Tooltip with a text content. - pub fn new(text: impl Into) -> Self { - Self { - style: StyleRefinement::default(), - content: TooltipContext::Text(text.into()), - key_binding: None, - action: None, - } - } - - /// Create a Tooltip with a custom element. - pub fn element(builder: F) -> Self - where - E: IntoElement, - F: Fn(&mut Window, &mut App) -> E + 'static, - { - Self { - style: StyleRefinement::default(), - key_binding: None, - action: None, - content: TooltipContext::Element(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })), - } - } - - /// Set Action to display key binding information for the tooltip if it exists. - pub fn action(mut self, action: &dyn Action, context: Option<&str>) -> Self { - self.action = Some((action.boxed_clone(), context.map(SharedString::new))); - self - } - - /// Set KeyBinding information for the tooltip. - pub fn key_binding(mut self, key_binding: Option) -> Self { - self.key_binding = key_binding; - self - } - - /// Build the tooltip and return it as an `AnyView`. - pub fn build(self, _: &mut Window, cx: &mut App) -> AnyView { - cx.new(|_| self).into() - } -} - -impl FluentBuilder for Tooltip {} -impl Styled for Tooltip { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} -impl Render for Tooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let key_binding = if let Some(key_binding) = &self.key_binding { - Some(key_binding.clone()) - } else { - if let Some((action, context)) = &self.action { - Kbd::binding_for_action( - action.as_ref(), - context.as_ref().map(|s| s.as_ref()), - window, - ) - } else { - None - } - }; - - div().child( - // Wrap in a child, to ensure the left margin is applied to the tooltip - h_flex() - .font_family(cx.theme().font_family.clone()) - .m_3() - .bg(cx.theme().popover) - .text_color(cx.theme().popover_foreground) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_md() - .rounded(px(6.)) - .justify_between() - .py_0p5() - .px_2() - .text_sm() - .gap_3() - .refine_style(&self.style) - .map(|this| { - this.child(div().map(|this| match self.content { - TooltipContext::Text(ref text) => this.child(text.clone()), - TooltipContext::Element(ref builder) => this.child(builder(window, cx)), - })) - }) - .when_some(key_binding, |this, kbd| { - this.child( - div() - .text_xs() - .flex_shrink_0() - .text_color(cx.theme().muted_foreground) - .child(kbd.appearance(false)), - ) - }), - ) - } -} - -// ── Managed tooltip system ────────────────────────────────────────────────── - -/// Grace period: if a tooltip was hidden within this time, skip delay for next show. -const GRACE_PERIOD: Duration = Duration::from_millis(300); -/// Delay before showing a tooltip when no tooltip is currently active. -const SHOW_DELAY: Duration = Duration::from_millis(500); -/// Duration of the slide-down enter animation. -const ENTER_DURATION: Duration = Duration::from_millis(150); -/// Duration of the position-slide animation when switching tooltips. -const SLIDE_DURATION: Duration = Duration::from_millis(200); -const TOOLTIP_WINDOW_MARGIN: Pixels = px(4.); - -#[derive(Clone, Copy, Debug, PartialEq)] -enum TooltipPlacement { - Above, - Below, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct TooltipOverlayPosition { - bounds: Bounds, - placement: TooltipPlacement, -} - -fn tooltip_overlay_position( - trigger_bounds: Bounds, - tooltip_size: Size, - viewport_size: Size, - margin: Pixels, -) -> TooltipOverlayPosition { - let centered_x = trigger_bounds.center().x - tooltip_size.width.half(); - let above_bounds = Bounds::new( - point(centered_x, trigger_bounds.top() - tooltip_size.height), - tooltip_size, - ); - let below_bounds = Bounds::new(point(centered_x, trigger_bounds.bottom()), tooltip_size); - - let bottom_limit = (viewport_size.height - margin).max(margin); - let available_above = (trigger_bounds.top() - margin).max(px(0.)); - let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.)); - - let (bounds, placement) = if above_bounds.top() >= margin { - (above_bounds, TooltipPlacement::Above) - } else if below_bounds.bottom() <= bottom_limit { - (below_bounds, TooltipPlacement::Below) - } else if available_below >= available_above { - (below_bounds, TooltipPlacement::Below) - } else { - (above_bounds, TooltipPlacement::Above) - }; - - TooltipOverlayPosition { - bounds: clamp_tooltip_bounds(bounds, viewport_size, margin), - placement, - } -} - -fn clamp_tooltip_bounds( - mut bounds: Bounds, - viewport_size: Size, - margin: Pixels, -) -> Bounds { - let right_limit = (viewport_size.width - margin).max(margin); - let bottom_limit = (viewport_size.height - margin).max(margin); - - if bounds.right() > right_limit { - bounds.origin.x -= bounds.right() - right_limit; - } - if bounds.left() < margin { - bounds.origin.x = margin; - } - - if bounds.bottom() > bottom_limit { - bounds.origin.y -= bounds.bottom() - bottom_limit; - } - if bounds.top() < margin { - bounds.origin.y = margin; - } - - bounds -} - -struct TooltipOverlayPositioner { - trigger_bounds: Bounds, - children: Vec, -} - -struct TooltipOverlayPositionerState { - child_layout_ids: Vec, -} - -fn tooltip_overlay_positioner(trigger_bounds: Bounds) -> TooltipOverlayPositioner { - TooltipOverlayPositioner { - trigger_bounds, - children: Vec::new(), - } -} - -impl ParentElement for TooltipOverlayPositioner { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Element for TooltipOverlayPositioner { - type RequestLayoutState = TooltipOverlayPositionerState; - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let child_layout_ids = self - .children - .iter_mut() - .map(|child| child.request_layout(window, cx)) - .collect::>(); - - let layout_id = window.request_layout( - Style { - position: Position::Absolute, - display: Display::Flex, - ..Style::default() - }, - child_layout_ids.iter().copied(), - cx, - ); - - ( - layout_id, - TooltipOverlayPositionerState { child_layout_ids }, - ) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - if request_layout.child_layout_ids.is_empty() { - return; - } - - let mut child_min: Point = point(Pixels::MAX, Pixels::MAX); - let mut child_max = Point::default(); - for child_layout_id in &request_layout.child_layout_ids { - let child_bounds = window.layout_bounds(*child_layout_id); - child_min = child_min.min(&child_bounds.origin); - child_max = child_max.max(&child_bounds.bottom_right()); - } - - let tooltip_size: Size = (child_max - child_min).into(); - let client_inset = window.client_inset().unwrap_or(px(0.)); - let tooltip_position = tooltip_overlay_position( - self.trigger_bounds, - tooltip_size, - window.viewport_size(), - TOOLTIP_WINDOW_MARGIN + client_inset, - ); - - let offset = tooltip_position.bounds.origin - bounds.origin; - let offset = point(offset.x.round(), offset.y.round()); - - window.with_element_offset(offset, |window| { - for child in &mut self.children { - child.prepaint(window, cx); - } - }); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - _prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - for child in &mut self.children { - child.paint(window, cx); - } - } -} - -impl IntoElement for TooltipOverlayPositioner { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -/// Content for a managed tooltip. -#[derive(Clone)] -pub(crate) struct TooltipContent { - pub build: Rc AnyView>, - pub trigger_bounds: Bounds, -} - -/// Manages tooltip lifecycle: delay, grace period, animations, and rendering. -/// -/// A single instance lives in [`Root`] per window. Components register hover -/// via [`ManagedTooltipExt::managed_tooltip`] which calls into this overlay. -pub struct TooltipOverlay { - content: Option, - prev_trigger_bounds: Option>, - epoch: usize, - had_recent_tooltip: bool, - animation_epoch: usize, - is_switching: bool, - - _show_task: Option>, - _hide_task: Option>, -} - -impl TooltipOverlay { - pub fn new() -> Self { - Self { - content: None, - prev_trigger_bounds: None, - epoch: 0, - had_recent_tooltip: false, - animation_epoch: 0, - is_switching: false, - _show_task: None, - _hide_task: None, - } - } - - fn next_epoch(&mut self) -> usize { - self.epoch += 1; - self.epoch - } - - /// Request showing a tooltip. If another tooltip is active or was recently - /// hidden, shows immediately with a slide animation. Otherwise starts a delay. - pub(crate) fn request_show( - &mut self, - content: TooltipContent, - window: &mut Window, - cx: &mut Context, - ) { - // Cancel any pending hide - self._hide_task = None; - - let was_visible = self.content.is_some(); - let in_grace = self.had_recent_tooltip; - - if was_visible || in_grace { - // Switch: show immediately with slide animation - self.prev_trigger_bounds = self.content.as_ref().map(|c| c.trigger_bounds); - self.content = Some(content); - self._show_task = None; - self.is_switching = was_visible; - self.animation_epoch += 1; - cx.notify(); - } else { - // New: delay then show with slideDown - let epoch = self.next_epoch(); - let content = content.clone(); - self._show_task = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(SHOW_DELAY).await; - let _ = this.update_in(cx, |this, _, cx| { - if this.epoch != epoch { - return; - } - - this.content = Some(content); - this.prev_trigger_bounds = None; - this.is_switching = false; - this.animation_epoch += 1; - cx.notify(); - }); - })); - } - } - - /// Request hiding the current tooltip. Starts a brief grace period so that - /// moving to another tooltip-bearing element feels instant. - pub(crate) fn request_hide(&mut self, window: &mut Window, cx: &mut Context) { - // Cancel any pending show - self._show_task = None; - - if self.content.is_none() { - return; - } - - let epoch = self.next_epoch(); - self.had_recent_tooltip = true; - - self._hide_task = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(GRACE_PERIOD).await; - let _ = this.update_in(cx, |this, _, cx| { - if this.epoch != epoch { - return; - } - this.content = None; - this.prev_trigger_bounds = None; - this.had_recent_tooltip = false; - cx.notify(); - }); - })); - } - - pub(crate) fn hide(&mut self, cx: &mut Context) { - if self.clear_state() { - cx.notify(); - } - } - - fn clear_state(&mut self) -> bool { - let changed = self.content.is_some() - || self.prev_trigger_bounds.is_some() - || self.had_recent_tooltip - || self.is_switching - || self._show_task.is_some() - || self._hide_task.is_some(); - - self.content = None; - self.prev_trigger_bounds = None; - self.had_recent_tooltip = false; - self.is_switching = false; - self._show_task = None; - self._hide_task = None; - - changed - } -} - -impl Render for TooltipOverlay { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(content) = self.content.as_ref() else { - return div().into_any_element(); - }; - - let content_view = (content.build)(window, cx); - let trigger_bounds = content.trigger_bounds; - let animation_epoch = self.animation_epoch; - let is_switching = self.is_switching; - let prev_trigger_bounds = self.prev_trigger_bounds; - - deferred( - tooltip_overlay_positioner(trigger_bounds).child(div().child(content_view).map(|el| { - if is_switching { - let Some(prev_bounds) = prev_trigger_bounds else { - return el.into_any_element(); - }; - - let is_same_y = - (trigger_bounds.origin.y - prev_bounds.origin.y).abs() < px(10.); - if !is_same_y { - // If the new trigger is at a different Y level, don't slide horizontally - // to avoid weird diagonal movement. (We could consider sliding vertically - // in this case, but it might be less visually clear.) - return el.into_any_element(); - } - - let dx = trigger_bounds.center().x - prev_bounds.center().x; - - Transition::new(SLIDE_DURATION) - .ease(ease_in_out_cubic) - .slide_x(-dx, px(0.)) - .apply( - el, - ElementId::NamedInteger("tooltip-slide".into(), animation_epoch as u64), - ) - .into_any_element() - } else { - // New tooltip: slideDown + fadeIn - Transition::new(ENTER_DURATION) - .ease(ease_out_cubic) - .slide_y(px(4.), px(0.)) - .fade(0.0, 1.0) - .apply( - el, - ElementId::NamedInteger("tooltip-enter".into(), animation_epoch as u64), - ) - .into_any_element() - } - })), - ) - .with_priority(2) - .into_any_element() - } -} - -// ── Extension trait for managed tooltips ───────────────────────────────────── - -// ── Shared tooltip state for components ───────────────────────────────────── - -/// Shared tooltip state that components (Button, Switch, Checkbox, Radio, etc.) -/// can embed to get `.tooltip()` support with minimal boilerplate. -#[derive(Default)] -pub(crate) struct ComponentTooltip { - pub text: Option<( - SharedString, - Option<(Rc>, Option)>, - )>, - pub builder: Option AnyView>>, -} - -impl ComponentTooltip { - /// Apply this tooltip to a `Stateful
` (or any `ManagedTooltipExt` element). - pub fn apply(self, el: E) -> E { - if let Some(builder) = self.builder { - el.managed_tooltip(move |window, cx| builder(window, cx)) - } else if let Some((text, action)) = self.text { - el.managed_tooltip(move |window, cx| { - Tooltip::new(text.clone()) - .when_some(action.clone(), |this, (action, context)| { - this.action( - action.boxed_clone().as_ref(), - context.as_ref().map(|c| c.as_ref()), - ) - }) - .build(window, cx) - }) - } else { - el - } - } -} - -// ── Internal managed tooltip trait ────────────────────────────────────────── - -pub(crate) trait ManagedTooltipExt: - StatefulInteractiveElement + crate::ElementExt + Sized -{ - fn managed_tooltip( - self, - build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, - ) -> Self { - let build_tooltip = Rc::new(build_tooltip); - let trigger_bounds_cell: Rc>> = Rc::new(Cell::new(Bounds::default())); - let bounds_writer = trigger_bounds_cell.clone(); - - self.on_prepaint(move |bounds, _, _| { - bounds_writer.set(bounds); - }) - .on_hover({ - let trigger_bounds_cell = trigger_bounds_cell.clone(); - let build_tooltip = build_tooltip.clone(); - move |hovered, window, cx| { - if let Some(overlay) = Root::tooltip_overlay(window, cx) { - if *hovered { - let bounds = trigger_bounds_cell.get(); - overlay.update(cx, |o: &mut TooltipOverlay, cx| { - o.request_show( - TooltipContent { - build: build_tooltip.clone(), - trigger_bounds: bounds, - }, - window, - cx, - ); - }); - } else { - overlay.update(cx, |o: &mut TooltipOverlay, cx| { - o.request_hide(window, cx); - }); - } - } - } - }) - .on_mouse_down(MouseButton::Left, move |_, window, cx| { - if let Some(overlay) = Root::tooltip_overlay(window, cx) { - overlay.update(cx, |overlay, cx| { - overlay.hide(cx); - }); - } - }) - } -} - -impl ManagedTooltipExt for E {} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::size; - - fn test_content(bounds: Bounds) -> TooltipContent { - TooltipContent { - build: Rc::new(|window, cx| Tooltip::new("Test tooltip").build(window, cx)), - trigger_bounds: bounds, - } - } - - fn test_bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { - Bounds::new(point(px(x), px(y)), size(px(width), px(height))) - } - - fn test_size(width: f32, height: f32) -> Size { - size(px(width), px(height)) - } - - #[test] - fn tooltip_overlay_clear_state_resets_active_tooltip() { - let mut overlay = TooltipOverlay::new(); - - overlay.content = Some(test_content(test_bounds(10., 10., 40., 20.))); - overlay.prev_trigger_bounds = Some(test_bounds(0., 0., 40., 20.)); - overlay.had_recent_tooltip = true; - overlay.is_switching = true; - overlay._show_task = Some(Task::ready(())); - - assert!(overlay.clear_state()); - assert!(overlay.content.is_none()); - assert!(overlay.prev_trigger_bounds.is_none()); - assert!(!overlay.had_recent_tooltip); - assert!(!overlay.is_switching); - assert!(overlay._show_task.is_none()); - assert!(overlay._hide_task.is_none()); - } - - #[test] - fn tooltip_overlay_position_prefers_above_when_space_allows() { - let trigger_bounds = test_bounds(100., 80., 80., 24.); - let position = tooltip_overlay_position( - trigger_bounds, - test_size(120., 30.), - test_size(300., 200.), - TOOLTIP_WINDOW_MARGIN, - ); - - assert_eq!(position.placement, TooltipPlacement::Above); - assert_eq!(position.bounds.origin.x, px(80.)); - assert_eq!(position.bounds.origin.y, px(50.)); - assert_eq!(position.bounds.bottom(), trigger_bounds.top()); - } - - #[test] - fn tooltip_overlay_position_flips_below_near_top_edge() { - let trigger_bounds = test_bounds(24., 4., 120., 32.); - let position = tooltip_overlay_position( - trigger_bounds, - test_size(240., 32.), - test_size(520., 260.), - TOOLTIP_WINDOW_MARGIN, - ); - - assert_eq!(position.placement, TooltipPlacement::Below); - assert_eq!(position.bounds.top(), trigger_bounds.bottom()); - assert!(position.bounds.top() >= trigger_bounds.bottom()); - } - - #[test] - fn tooltip_overlay_position_clamps_horizontal_edges() { - let trigger_bounds = test_bounds(4., 80., 24., 24.); - let position = tooltip_overlay_position( - trigger_bounds, - test_size(120., 30.), - test_size(300., 200.), - TOOLTIP_WINDOW_MARGIN, - ); - - assert_eq!(position.placement, TooltipPlacement::Above); - assert_eq!(position.bounds.left(), TOOLTIP_WINDOW_MARGIN); - } - - #[test] - fn tooltip_overlay_position_uses_larger_side_when_neither_side_fits() { - let trigger_bounds = test_bounds(120., 20., 40., 20.); - let position = tooltip_overlay_position( - trigger_bounds, - test_size(160., 120.), - test_size(300., 100.), - TOOLTIP_WINDOW_MARGIN, - ); - - assert_eq!(position.placement, TooltipPlacement::Below); - assert_eq!(position.bounds.top(), TOOLTIP_WINDOW_MARGIN); - assert_eq!(position.bounds.left(), px(60.)); - } -} diff --git a/vendor/gpui-component/crates/ui/src/tree.rs b/vendor/gpui-component/crates/ui/src/tree.rs deleted file mode 100644 index f4865c19..00000000 --- a/vendor/gpui-component/crates/ui/src/tree.rs +++ /dev/null @@ -1,677 +0,0 @@ -use std::{cell::RefCell, ops::Range, rc::Rc}; - -use gpui::{ - App, Context, ElementId, Entity, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, - ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, SharedString, - StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::FluentBuilder as _, - uniform_list, -}; - -use crate::{ - Selectable as _, StyledExt, - actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}, - list::ListItem, - menu::{ContextMenuExt as _, PopupMenu}, - scroll::ScrollableElement, -}; - -const CONTEXT: &str = "Tree"; -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("up", SelectUp, Some(CONTEXT)), - KeyBinding::new("down", SelectDown, Some(CONTEXT)), - KeyBinding::new("left", SelectLeft, Some(CONTEXT)), - KeyBinding::new("right", SelectRight, Some(CONTEXT)), - ]); -} - -/// Create a [`Tree`]. -/// -/// # Arguments -/// -/// * `state` - The shared state managing the tree items. -/// * `render_item` - A closure to render each tree item. -/// -/// ```ignore -/// let state = cx.new(|_| { -/// TreeState::new().items(vec![ -/// TreeItem::new("src") -/// .child(TreeItem::new("lib.rs"), -/// TreeItem::new("Cargo.toml"), -/// TreeItem::new("README.md"), -/// ]) -/// }); -/// -/// tree(&state, |ix, entry, selected, window, cx| { -/// let item = entry.item(); -/// ListItem::new(ix).pl(px(16.) * entry.depth()).child(item.label.clone()) -/// }) -/// ``` -pub fn tree(state: &Entity, render_item: R) -> Tree -where - R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static, -{ - Tree::new(state, render_item) -} - -struct TreeItemState { - expanded: bool, - disabled: bool, -} - -/// A tree item with a label, children, and an expanded state. -#[derive(Clone)] -pub struct TreeItem { - pub id: SharedString, - pub label: SharedString, - pub children: Vec, - state: Rc>, -} - -/// A flat representation of a tree item with its depth. -#[derive(Clone)] -pub struct TreeEntry { - item: TreeItem, - depth: usize, -} - -impl TreeEntry { - /// Get the source tree item. - #[inline] - pub fn item(&self) -> &TreeItem { - &self.item - } - - /// The depth of this item in the tree. - #[inline] - pub fn depth(&self) -> usize { - self.depth - } - - #[inline] - fn is_root(&self) -> bool { - self.depth == 0 - } - - /// Whether this item is a folder (has children). - #[inline] - pub fn is_folder(&self) -> bool { - self.item.is_folder() - } - - /// Return true if the item is expanded. - #[inline] - pub fn is_expanded(&self) -> bool { - self.item.is_expanded() - } - - #[inline] - pub fn is_disabled(&self) -> bool { - self.item.is_disabled() - } -} - -impl TreeItem { - /// Create a new tree item with the given label. - /// - /// - The `id` for you to uniquely identify this item, then later you can use it for selection or other purposes. - /// - The `label` is the text to display for this item. - /// - /// For example, the `id` is the full file path, and the `label` is the file name. - /// - /// ```ignore - /// TreeItem::new("src/ui/button.rs", "button.rs") - /// ``` - pub fn new(id: impl Into, label: impl Into) -> Self { - Self { - id: id.into(), - label: label.into(), - children: Vec::new(), - state: Rc::new(RefCell::new(TreeItemState { - expanded: false, - disabled: false, - })), - } - } - - /// Add a child item to this tree item. - pub fn child(mut self, child: TreeItem) -> Self { - self.children.push(child); - self - } - - /// Add multiple child items to this tree item. - pub fn children(mut self, children: impl IntoIterator) -> Self { - self.children.extend(children); - self - } - - /// Set expanded state for this tree item. - pub fn expanded(self, expanded: bool) -> Self { - self.state.borrow_mut().expanded = expanded; - self - } - - /// Set disabled state for this tree item. - pub fn disabled(self, disabled: bool) -> Self { - self.state.borrow_mut().disabled = disabled; - self - } - - /// Whether this item is a folder (has children). - #[inline] - pub fn is_folder(&self) -> bool { - self.children.len() > 0 - } - - /// Return true if the item is disabled. - pub fn is_disabled(&self) -> bool { - self.state.borrow().disabled - } - - /// Return true if the item is expanded. - #[inline] - pub fn is_expanded(&self) -> bool { - self.state.borrow().expanded - } - - fn find_ancestors(&self, target_id: &SharedString) -> Option> { - if self.id == *target_id { - return Some(vec![]); - } - - for child in &self.children { - if let Some(mut path) = child.find_ancestors(target_id) { - path.push(self.clone()); - return Some(path); - } - } - - None - } -} - -/// State for managing tree items. -pub struct TreeState { - focus_handle: FocusHandle, - entries: Vec, - scroll_handle: UniformListScrollHandle, - selected_ix: Option, - right_clicked_ix: Option, - render_item: Rc ListItem>, - context_menu_builder: Option< - Rc) -> PopupMenu>, - >, -} - -impl TreeState { - /// Create a new empty tree state. - pub fn new(cx: &mut App) -> Self { - Self { - selected_ix: None, - right_clicked_ix: None, - focus_handle: cx.focus_handle(), - scroll_handle: UniformListScrollHandle::default(), - entries: Vec::new(), - render_item: Rc::new(|_, _, _, _, _| ListItem::new(0)), - context_menu_builder: None, - } - } - - /// Set the tree items. - pub fn items(mut self, items: impl Into>) -> Self { - let items = items.into(); - self.entries.clear(); - for item in items.into_iter() { - self.add_entry(item, 0); - } - self - } - - /// Set the tree items. - pub fn set_items(&mut self, items: impl Into>, cx: &mut Context) { - let items = items.into(); - self.entries.clear(); - for item in items.into_iter() { - self.add_entry(item, 0); - } - self.selected_ix = None; - self.right_clicked_ix = None; - cx.notify(); - } - - /// Get the currently selected index, if any. - pub fn selected_index(&self) -> Option { - self.selected_ix - } - - /// Set the selected index, or `None` to clear selection. - pub fn set_selected_index(&mut self, ix: Option, cx: &mut Context) { - self.selected_ix = ix; - cx.notify(); - } - - /// Set the selected index by tree item, or `None` to clear selection. - pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context) { - if let Some(item) = item { - let ix = self - .entries - .iter() - .position(|entry| entry.item.id == item.id); - if ix.is_some() { - self.selected_ix = ix; - } else { - self.expand_ancestors(item.id.clone()); - self.selected_ix = self - .entries - .iter() - .position(|entry| entry.item.id == item.id); - } - } else { - self.selected_ix = None; - } - cx.notify(); - } - - /// Get the currently selected tree item, if any. - pub fn selected_item(&self) -> Option<&TreeItem> { - self.selected_ix - .and_then(|ix| self.entries.get(ix).map(|entry| &entry.item)) - } - - pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) { - self.scroll_handle.scroll_to_item(ix, strategy); - } - - /// Get the currently selected entry, if any. - pub fn selected_entry(&self) -> Option<&TreeEntry> { - self.selected_ix.and_then(|ix| self.entries.get(ix)) - } - - fn expand_ancestors(&mut self, target_id: SharedString) { - let mut ancestors = Vec::new(); - - for entry in &self.entries { - if let Some(found_ancestors) = entry.item.find_ancestors(&target_id) { - ancestors = found_ancestors; - break; - } - } - - if ancestors.is_empty() { - return; - } - - for ancestor in ancestors { - ancestor.state.borrow_mut().expanded = true; - } - - self.rebuild_entries(); - } - - fn add_entry(&mut self, item: TreeItem, depth: usize) { - self.entries.push(TreeEntry { - item: item.clone(), - depth, - }); - if item.is_expanded() { - for child in &item.children { - self.add_entry(child.clone(), depth + 1); - } - } - } - - fn toggle_expand(&mut self, ix: usize) { - let Some(entry) = self.entries.get_mut(ix) else { - return; - }; - if !entry.is_folder() { - return; - } - - entry.item.state.borrow_mut().expanded = !entry.is_expanded(); - self.right_clicked_ix = None; - self.rebuild_entries(); - } - - fn rebuild_entries(&mut self) { - let root_items: Vec = self - .entries - .iter() - .filter(|e| e.is_root()) - .map(|e| e.item.clone()) - .collect(); - self.entries.clear(); - for item in root_items.into_iter() { - self.add_entry(item, 0); - } - } - - pub fn focus(&mut self, window: &mut Window, cx: &mut App) { - self.focus_handle.focus(window, cx); - } - - fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { - if let Some(selected_ix) = self.selected_ix { - if let Some(entry) = self.entries.get(selected_ix) { - if entry.is_folder() { - self.toggle_expand(selected_ix); - cx.notify(); - } - } - } - } - - fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { - if let Some(selected_ix) = self.selected_ix { - if let Some(entry) = self.entries.get(selected_ix) { - if entry.is_folder() && entry.is_expanded() { - self.toggle_expand(selected_ix); - cx.notify(); - } - } - } - } - - fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - if let Some(selected_ix) = self.selected_ix { - if let Some(entry) = self.entries.get(selected_ix) { - if entry.is_folder() && !entry.is_expanded() { - self.toggle_expand(selected_ix); - cx.notify(); - } - } - } - } - - fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { - let mut selected_ix = self.selected_ix.unwrap_or(0); - - if selected_ix > 0 { - selected_ix = selected_ix - 1; - } else { - selected_ix = self.entries.len().saturating_sub(1); - } - - self.selected_ix = Some(selected_ix); - self.scroll_handle - .scroll_to_item(selected_ix, gpui::ScrollStrategy::Top); - cx.notify(); - } - - fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { - let mut selected_ix = self.selected_ix.unwrap_or(0); - if selected_ix + 1 < self.entries.len() { - selected_ix = selected_ix + 1; - } else { - selected_ix = 0; - } - - self.selected_ix = Some(selected_ix); - self.scroll_handle - .scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom); - cx.notify(); - } - - fn on_entry_click(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.selected_ix = Some(ix); - self.toggle_expand(ix); - cx.notify(); - } -} - -impl Render for TreeState { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let render_item = self.render_item.clone(); - let state = cx.entity().clone(); - - div() - .id("tree-state") - .size_full() - .relative() - .context_menu({ - let state = state.clone(); - move |menu, window, cx: &mut Context| { - if state.read(cx).context_menu_builder.is_none() { - return menu; - } - - let (ix, entry) = { - let state = state.read(cx); - let entry = state - .right_clicked_ix - .and_then(|ix| state.entries.get(ix).cloned()); - (state.right_clicked_ix, entry) - }; - - if let (Some(ix), Some(entry)) = (ix, entry) { - state.update(cx, |state, cx| { - if let Some(build) = state.context_menu_builder.clone() { - build(ix, &entry, menu, window, cx) - } else { - menu - } - }) - } else { - menu - } - } - }) - .child( - uniform_list("entries", self.entries.len(), { - cx.processor(move |state, visible_range: Range, window, cx| { - let mut items = Vec::with_capacity(visible_range.len()); - for ix in visible_range { - let entry = &state.entries[ix]; - let selected = Some(ix) == state.selected_ix; - let right_clicked = Some(ix) == state.right_clicked_ix; - let item = (render_item)(ix, entry, selected, window, cx); - - let el = div() - .id(ix) - .child( - item.disabled(entry.item().is_disabled()) - .selected(selected) - .secondary_selected(right_clicked), - ) - .when(!entry.item().is_disabled(), |this| { - this.on_mouse_down( - MouseButton::Left, - cx.listener({ - move |this, _, window, cx| { - this.on_entry_click(ix, window, cx); - } - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, _, _, cx| { - this.right_clicked_ix = Some(ix); - cx.notify(); - }), - ) - }); - - items.push(el) - } - - items - }) - }) - .flex_grow() - .size_full() - .track_scroll(&self.scroll_handle) - .with_sizing_behavior(ListSizingBehavior::Auto) - .into_any_element(), - ) - } -} - -/// A tree view element that displays hierarchical data. -#[derive(IntoElement)] -pub struct Tree { - id: ElementId, - state: Entity, - style: StyleRefinement, - render_item: Rc ListItem>, - context_menu_builder: Option< - Rc) -> PopupMenu>, - >, -} - -impl Tree { - pub fn new(state: &Entity, render_item: R) -> Self - where - R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static, - { - Self { - id: ElementId::Name(format!("tree-{}", state.entity_id()).into()), - state: state.clone(), - style: StyleRefinement::default(), - render_item: Rc::new(move |ix, item, selected, window, app| { - render_item(ix, item, selected, window, app) - }), - context_menu_builder: None, - } - } - - /// Add a context menu to the tree. - /// - /// The closure receives: - /// - `ix`: the index of the right-clicked entry - /// - `entry`: the right-clicked tree entry - /// - `menu`: the popup menu builder - pub fn context_menu(mut self, f: F) -> Self - where - F: Fn(usize, &TreeEntry, PopupMenu, &mut Window, &mut Context) -> PopupMenu - + 'static, - { - self.context_menu_builder = Some(Rc::new(f)); - self - } -} - -impl Styled for Tree { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for Tree { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let focus_handle = self.state.read(cx).focus_handle.clone(); - let scroll_handle = self.state.read(cx).scroll_handle.clone(); - - self.state.update(cx, |state, _| { - state.render_item = self.render_item; - state.context_menu_builder = self.context_menu_builder; - }); - - div() - .id(self.id) - .key_context(CONTEXT) - .track_focus(&focus_handle) - .on_action(window.listener_for(&self.state, TreeState::on_action_confirm)) - .on_action(window.listener_for(&self.state, TreeState::on_action_left)) - .on_action(window.listener_for(&self.state, TreeState::on_action_right)) - .on_action(window.listener_for(&self.state, TreeState::on_action_up)) - .on_action(window.listener_for(&self.state, TreeState::on_action_down)) - .size_full() - .child(self.state) - .refine_style(&self.style) - .vertical_scrollbar(&scroll_handle) - } -} - -#[cfg(test)] -mod tests { - use indoc::indoc; - - use super::TreeState; - use gpui::AppContext as _; - - fn assert_entries(entries: &Vec, expected: &str) { - let actual: Vec = entries - .iter() - .map(|e| { - let mut s = String::new(); - s.push_str(&" ".repeat(e.depth)); - s.push_str(e.item().label.as_str()); - s - }) - .collect(); - let actual = actual.join("\n"); - assert_eq!(actual.trim(), expected.trim()); - } - - #[gpui::test] - fn test_tree_entry(cx: &mut gpui::TestAppContext) { - use super::TreeItem; - - let items = vec![ - TreeItem::new("src", "src") - .expanded(true) - .child( - TreeItem::new("src/ui", "ui") - .expanded(true) - .child(TreeItem::new("src/ui/button.rs", "button.rs")) - .child(TreeItem::new("src/ui/icon.rs", "icon.rs")) - .child(TreeItem::new("src/ui/mod.rs", "mod.rs")), - ) - .child(TreeItem::new("src/lib.rs", "lib.rs")), - TreeItem::new("Cargo.toml", "Cargo.toml"), - TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true), - TreeItem::new("README.md", "README.md"), - ]; - - let state = cx.new(|cx| TreeState::new(cx).items(items)); - state.update(cx, |state, _| { - assert_entries( - &state.entries, - indoc! { - r#" - src - ui - button.rs - icon.rs - mod.rs - lib.rs - Cargo.toml - Cargo.lock - README.md - "# - }, - ); - - let entry = state.entries.get(0).unwrap(); - assert_eq!(entry.depth(), 0); - assert_eq!(entry.is_root(), true); - assert_eq!(entry.is_folder(), true); - assert_eq!(entry.is_expanded(), true); - - let entry = state.entries.get(1).unwrap(); - assert_eq!(entry.depth(), 1); - assert_eq!(entry.is_root(), false); - assert_eq!(entry.is_folder(), true); - assert_eq!(entry.is_expanded(), true); - assert_eq!(entry.item().label.as_str(), "ui"); - - state.toggle_expand(1); - let entry = state.entries.get(1).unwrap(); - assert_eq!(entry.is_expanded(), false); - assert_entries( - &state.entries, - indoc! { - r#" - src - ui - lib.rs - Cargo.toml - Cargo.lock - README.md - "# - }, - ); - }) - } -} diff --git a/vendor/gpui-component/crates/ui/src/virtual_list.rs b/vendor/gpui-component/crates/ui/src/virtual_list.rs deleted file mode 100644 index d71422ca..00000000 --- a/vendor/gpui-component/crates/ui/src/virtual_list.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! Virtual List for render a large number of differently sized rows/columns. -//! -//! > NOTE: This must ensure each column width or row height. -//! -//! Only visible range are rendered for performance reasons. -//! -//! Inspired by `gpui::uniform_list`. -//! https://github.com/zed-industries/zed/blob/0ae1603610ab6b265bdfbee7b8dbc23c5ab06edc/crates/gpui/src/elements/uniform_list.rs -//! -//! Unlike the `uniform_list`, the each item can have different size. -//! -//! This is useful for more complex layout, for example, a table with different row height. -use std::{ - cell::RefCell, - cmp, - ops::{Deref, Range}, - rc::Rc, -}; - -use gpui::{ - Along, AnyElement, App, AvailableSpace, Axis, Bounds, ContentMask, Context, - DeferredScrollToItem, Div, Element, ElementId, Entity, GlobalElementId, Half, Hitbox, - InteractiveElement, IntoElement, IsZero as _, ListSizingBehavior, Pixels, Point, Render, - ScrollHandle, ScrollStrategy, Size, Stateful, StatefulInteractiveElement, StyleRefinement, - Styled, Window, div, point, px, size, -}; -use smallvec::SmallVec; - -use crate::{AxisExt, scroll::ScrollbarHandle}; - -struct VirtualListScrollHandleState { - axis: Axis, - items_count: usize, - pub deferred_scroll_to_item: Option, -} - -/// A scroll handle for [`VirtualList`]. -/// -/// See also [`ScrollHandle`]. -#[derive(Clone)] -pub struct VirtualListScrollHandle { - state: Rc>, - base_handle: ScrollHandle, -} - -impl From for VirtualListScrollHandle { - fn from(handle: ScrollHandle) -> Self { - let mut this = VirtualListScrollHandle::new(); - this.base_handle = handle; - this - } -} - -impl AsRef for VirtualListScrollHandle { - fn as_ref(&self) -> &ScrollHandle { - &self.base_handle - } -} - -impl ScrollbarHandle for VirtualListScrollHandle { - fn offset(&self) -> Point { - self.base_handle.offset() - } - - fn set_offset(&self, offset: Point) { - self.base_handle.set_offset(offset); - } - - fn content_size(&self) -> Size { - self.base_handle.content_size() - } -} - -impl Deref for VirtualListScrollHandle { - type Target = ScrollHandle; - - fn deref(&self) -> &Self::Target { - &self.base_handle - } -} - -impl VirtualListScrollHandle { - /// Create a new VirtualListScrollHandle. - pub fn new() -> Self { - VirtualListScrollHandle { - state: Rc::new(RefCell::new(VirtualListScrollHandleState { - axis: Axis::Vertical, - items_count: 0, - deferred_scroll_to_item: None, - })), - base_handle: ScrollHandle::default(), - } - } - - /// Get the base scroll handle. - pub fn base_handle(&self) -> &ScrollHandle { - &self.base_handle - } - - /// Scroll to the item at the given index. - pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) { - self.scroll_to_item_with_offset(ix, strategy, 0); - } - - /// Scroll to the item at the given index, with an additional offset items. - fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) { - let mut state = self.state.borrow_mut(); - state.deferred_scroll_to_item = Some(DeferredScrollToItem { - item_index: ix, - strategy, - offset, - scroll_strict: false, - }); - } - - /// Scrolls to the bottom of the list. - pub fn scroll_to_bottom(&self) { - let items_count = self.state.borrow().items_count; - self.scroll_to_item(items_count.saturating_sub(1), ScrollStrategy::Top); - } -} - -/// Create a [`VirtualList`] in vertical direction. -/// -/// This is like `uniform_list` in GPUI, but support two axis. -/// -/// The `item_sizes` is the size of each column, -/// only the `height` is used, `width` is ignored and VirtualList will measure the first item width. -/// -/// See also [`h_virtual_list`] -#[inline] -pub fn v_virtual_list( - view: Entity, - id: impl Into, - item_sizes: Rc>>, - f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, -) -> VirtualList -where - R: IntoElement, - V: Render, -{ - virtual_list(view, id, Axis::Vertical, item_sizes, f) -} - -/// Create a [`VirtualList`] in horizontal direction. -/// -/// The `item_sizes` is the size of each column, -/// only the `width` is used, `height` is ignored and VirtualList will measure the first item height. -/// -/// See also [`v_virtual_list`] -#[inline] -pub fn h_virtual_list( - view: Entity, - id: impl Into, - item_sizes: Rc>>, - f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, -) -> VirtualList -where - R: IntoElement, - V: Render, -{ - virtual_list(view, id, Axis::Horizontal, item_sizes, f) -} - -pub(crate) fn virtual_list( - view: Entity, - id: impl Into, - axis: Axis, - item_sizes: Rc>>, - f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, -) -> VirtualList -where - R: IntoElement, - V: Render, -{ - let id: ElementId = id.into(); - let scroll_handle = VirtualListScrollHandle::new(); - let render_range = move |visible_range, window: &mut Window, cx: &mut App| { - view.update(cx, |this, cx| { - f(this, visible_range, window, cx) - .into_iter() - .map(|component| component.into_any_element()) - .collect() - }) - }; - - VirtualList { - id: id.clone(), - axis, - base: div() - .id(id) - .size_full() - .overflow_scroll() - .track_scroll(&scroll_handle), - scroll_handle, - items_count: item_sizes.len(), - item_sizes, - render_items: Box::new(render_range), - sizing_behavior: ListSizingBehavior::default(), - } -} - -/// VirtualList component for rendering a large number of differently sized items. -pub struct VirtualList { - id: ElementId, - axis: Axis, - base: Stateful
, - scroll_handle: VirtualListScrollHandle, - items_count: usize, - item_sizes: Rc>>, - render_items: Box< - dyn for<'a> Fn(Range, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>, - >, - sizing_behavior: ListSizingBehavior, -} - -impl Styled for VirtualList { - fn style(&mut self) -> &mut StyleRefinement { - self.base.style() - } -} - -impl VirtualList { - pub fn track_scroll(mut self, scroll_handle: &VirtualListScrollHandle) -> Self { - self.base = self.base.track_scroll(&scroll_handle); - self.scroll_handle = scroll_handle.clone(); - self - } - - /// Set the sizing behavior for the list. - pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self { - self.sizing_behavior = behavior; - self - } - - /// Specify for table. - /// - /// Table is special, because the `scroll_handle` is based on Table head (That is not a virtual list). - pub(crate) fn with_scroll_handle(mut self, scroll_handle: &VirtualListScrollHandle) -> Self { - self.base = div().id(self.id.clone()).size_full(); - self.scroll_handle = scroll_handle.clone(); - self - } - - fn scroll_to_deferred_item( - &self, - scroll_offset: Point, - items_bounds: &[Bounds], - content_bounds: &Bounds, - scroll_to_item: DeferredScrollToItem, - ) -> Point { - let Some(bounds) = items_bounds - .get(scroll_to_item.item_index + scroll_to_item.offset) - .cloned() - else { - return scroll_offset; - }; - - let mut scroll_offset = scroll_offset; - match scroll_to_item.strategy { - ScrollStrategy::Top => { - if self.axis.is_vertical() { - scroll_offset.y = content_bounds.top() - bounds.top(); - } else { - scroll_offset.x = content_bounds.left() - bounds.left(); - } - } - ScrollStrategy::Center => { - if self.axis.is_vertical() { - scroll_offset.y = content_bounds.top() + content_bounds.size.height.half() - - bounds.top() - - bounds.size.height.half() - } else { - scroll_offset.x = content_bounds.left() + content_bounds.size.width.half() - - bounds.left() - - bounds.size.width.half() - } - } - ScrollStrategy::Bottom => { - if self.axis.is_vertical() { - scroll_offset.y = content_bounds.bottom() - bounds.bottom(); - } else { - scroll_offset.x = content_bounds.right() - bounds.right(); - } - } - ScrollStrategy::Nearest => { - // Ref: https://github.com/zed-industries/zed/blob/0d145289e0867a8d5d63e5e1397a5ca69c9d49c3/crates/gpui/src/elements/div.rs#L3026 - if self.axis.is_vertical() { - if bounds.top() + scroll_offset.y < content_bounds.top() { - scroll_offset.y = content_bounds.top() - bounds.top() - } else if bounds.bottom() + scroll_offset.y > content_bounds.bottom() { - scroll_offset.y = content_bounds.bottom() - bounds.bottom(); - } - } else { - if bounds.left() + scroll_offset.x < content_bounds.left() { - scroll_offset.x = content_bounds.left() - bounds.left(); - } else if bounds.right() + scroll_offset.x > content_bounds.right() { - scroll_offset.x = content_bounds.right() - bounds.right(); - } - } - } - } - self.scroll_handle.set_offset(scroll_offset); - scroll_offset - } - - /// Ref from: https://github.com/zed-industries/zed/blob/83f9f9d9e3f5914392cab9a09e3472711a1d7b38/crates/gpui/src/elements/uniform_list.rs#L660 - fn measure_item( - &self, - list_width: Option, - window: &mut Window, - cx: &mut App, - ) -> Size { - if self.items_count == 0 { - return Size::default(); - } - - let item_ix = 0; - let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx); - let Some(mut item_to_measure) = items.pop() else { - return Size::default(); - }; - let available_space = size( - list_width.map_or(AvailableSpace::MinContent, |width| { - AvailableSpace::Definite(width) - }), - AvailableSpace::MinContent, - ); - item_to_measure.layout_as_root(available_space, window, cx) - } -} - -/// Frame state used by the [VirtualItem]. -pub struct VirtualListFrameState { - /// Visible items to be painted. - items: SmallVec<[AnyElement; 32]>, - size_layout: ItemSizeLayout, -} - -#[derive(Default, Clone)] -pub struct ItemSizeLayout { - items_sizes: Rc>>, - content_size: Size, - sizes: Vec, - origins: Vec, - last_layout_bounds: Bounds, -} - -impl IntoElement for VirtualList { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for VirtualList { - type RequestLayoutState = VirtualListFrameState; - type PrepaintState = Option; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let rem_size = window.rem_size(); - let font_size = window.text_style().font_size.to_pixels(rem_size); - let mut size_layout = ItemSizeLayout::default(); - let longest_item_size = self.measure_item(None, window, cx); - - let layout_id = self.base.interactivity().request_layout( - global_id, - inspector_id, - window, - cx, - |style, window, cx| { - size_layout = window.with_element_state( - global_id.unwrap(), - |state: Option, _window| { - let mut state = state.unwrap_or(ItemSizeLayout::default()); - - // Including the gap between items for calculate the item size - let gap = style - .gap - .along(self.axis) - .to_pixels(font_size.into(), rem_size); - - if state.items_sizes != self.item_sizes { - state.items_sizes = self.item_sizes.clone(); - // Prepare each item's size by axis - state.sizes = self - .item_sizes - .iter() - .enumerate() - .map(|(i, size)| { - let size = size.along(self.axis); - if i + 1 == self.items_count { - size - } else { - size + gap - } - }) - .collect::>(); - - // Prepare each item's origin by axis - state.origins = state - .sizes - .iter() - .scan(px(0.), |cumulative, size| match self.axis { - Axis::Horizontal => { - let x = *cumulative; - *cumulative += *size; - Some(x) - } - Axis::Vertical => { - let y = *cumulative; - *cumulative += *size; - Some(y) - } - }) - .collect::>(); - - state.content_size = if self.axis.is_horizontal() { - Size { - width: px(state - .sizes - .iter() - .map(|size| size.as_f32()) - .sum::()), - height: longest_item_size.height, - } - } else { - Size { - width: longest_item_size.width, - height: px(state - .sizes - .iter() - .map(|size| size.as_f32()) - .sum::()), - } - }; - } - - (state.clone(), state) - }, - ); - - let axis = self.axis; - let layout_id = - match self.sizing_behavior { - ListSizingBehavior::Infer => { - window.with_text_style(style.text_style().cloned(), |window| { - let size_layout = size_layout.clone(); - - window.request_measured_layout(style, { - move |known_dimensions, available_space, _, _| { - let mut size = Size::default(); - if axis.is_horizontal() { - size.width = known_dimensions.width.unwrap_or( - match available_space.width { - AvailableSpace::Definite(x) => x, - AvailableSpace::MinContent - | AvailableSpace::MaxContent => { - size_layout.content_size.width - } - }, - ); - size.height = known_dimensions.width.unwrap_or( - match available_space.height { - AvailableSpace::Definite(x) => x, - AvailableSpace::MinContent - | AvailableSpace::MaxContent => { - size_layout.content_size.height - } - }, - ); - } else { - size.width = known_dimensions.width.unwrap_or( - match available_space.width { - AvailableSpace::Definite(x) => x, - AvailableSpace::MinContent - | AvailableSpace::MaxContent => { - size_layout.content_size.width - } - }, - ); - size.height = known_dimensions.height.unwrap_or( - match available_space.height { - AvailableSpace::Definite(x) => x, - AvailableSpace::MinContent - | AvailableSpace::MaxContent => { - size_layout.content_size.height - } - }, - ); - } - - size - } - }) - }) - } - ListSizingBehavior::Auto => window - .with_text_style(style.text_style().cloned(), |window| { - window.request_layout(style, None, cx) - }), - }; - - layout_id - }, - ); - - ( - layout_id, - VirtualListFrameState { - items: SmallVec::new(), - size_layout, - }, - ) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - layout.size_layout.last_layout_bounds = bounds; - - let style = self - .base - .interactivity() - .compute_style(global_id, None, window, cx); - let border_widths = style.border_widths.to_pixels(window.rem_size()); - let paddings = style - .padding - .to_pixels(bounds.size.into(), window.rem_size()); - - let item_sizes = &layout.size_layout.sizes; - let item_origins = &layout.size_layout.origins; - - let content_bounds = Bounds::from_corners( - bounds.origin - + point( - border_widths.left + paddings.left, - border_widths.top + paddings.top, - ), - bounds.bottom_right() - - point( - border_widths.right + paddings.right, - border_widths.bottom + paddings.bottom, - ), - ); - - // Update scroll_handle with the item bounds - let items_bounds = item_origins - .iter() - .enumerate() - .map(|(i, &origin)| { - let item_size = item_sizes[i]; - - Bounds { - origin: match self.axis { - Axis::Horizontal => point(content_bounds.left() + origin, px(0.)), - Axis::Vertical => point(px(0.), content_bounds.top() + origin), - }, - size: match self.axis { - Axis::Horizontal => size(item_size, content_bounds.size.height), - Axis::Vertical => size(content_bounds.size.width, item_size), - }, - } - }) - .collect::>(); - - let axis = self.axis; - - let mut scroll_state = self.scroll_handle.state.borrow_mut(); - scroll_state.axis = axis; - scroll_state.items_count = self.items_count; - - let mut scroll_offset = self.scroll_handle.offset(); - if let Some(scroll_to_item) = scroll_state.deferred_scroll_to_item.take() { - scroll_offset = self.scroll_to_deferred_item( - scroll_offset, - &items_bounds, - &content_bounds, - scroll_to_item, - ); - } - - scroll_offset = scroll_offset - .max(&point( - content_bounds.size.width - layout.size_layout.content_size.width, - content_bounds.size.height - layout.size_layout.content_size.height, - )) - .min(&point(px(0.), px(0.))); - if scroll_offset != self.scroll_handle.offset() { - self.scroll_handle.set_offset(scroll_offset); - } - - self.base.interactivity().prepaint( - global_id, - inspector_id, - bounds, - layout.size_layout.content_size, - window, - cx, - |_style, _, hitbox, window, cx| { - if self.items_count > 0 { - let min_scroll_offset = content_bounds.size.along(self.axis) - - layout.size_layout.content_size.along(self.axis); - - let is_scrolled = !scroll_offset.along(self.axis).is_zero(); - if is_scrolled { - match self.axis { - Axis::Horizontal if scroll_offset.x < min_scroll_offset => { - scroll_offset.x = min_scroll_offset; - self.scroll_handle.set_offset(scroll_offset); - } - Axis::Vertical if scroll_offset.y < min_scroll_offset => { - scroll_offset.y = min_scroll_offset; - self.scroll_handle.set_offset(scroll_offset); - } - _ => {} - } - } - - let (first_visible_element_ix, last_visible_element_ix) = match self.axis { - Axis::Horizontal => { - let mut cumulative_size = px(0.); - let mut first_visible_element_ix = 0; - for (i, &size) in item_sizes.iter().enumerate() { - cumulative_size += size; - if cumulative_size > -(scroll_offset.x + paddings.left) { - first_visible_element_ix = i; - break; - } - } - - cumulative_size = px(0.); - let mut last_visible_element_ix = 0; - for (i, &size) in item_sizes.iter().enumerate() { - cumulative_size += size; - if cumulative_size > (-scroll_offset.x + content_bounds.size.width) - { - last_visible_element_ix = i + 1; - break; - } - } - if last_visible_element_ix == 0 { - last_visible_element_ix = self.items_count; - } else { - last_visible_element_ix += 1; - } - (first_visible_element_ix, last_visible_element_ix) - } - Axis::Vertical => { - let mut cumulative_size = px(0.); - let mut first_visible_element_ix = 0; - for (i, &size) in item_sizes.iter().enumerate() { - cumulative_size += size; - if cumulative_size > -(scroll_offset.y + paddings.top) { - first_visible_element_ix = i; - break; - } - } - - cumulative_size = px(0.); - let mut last_visible_element_ix = 0; - for (i, &size) in item_sizes.iter().enumerate() { - cumulative_size += size; - if cumulative_size > (-scroll_offset.y + content_bounds.size.height) - { - last_visible_element_ix = i + 1; - break; - } - } - if last_visible_element_ix == 0 { - last_visible_element_ix = self.items_count; - } else { - last_visible_element_ix += 1; - } - (first_visible_element_ix, last_visible_element_ix) - } - }; - - let visible_range = first_visible_element_ix - ..cmp::min(last_visible_element_ix, self.items_count); - - let items = (self.render_items)(visible_range.clone(), window, cx); - - let content_mask = ContentMask { bounds }; - window.with_content_mask(Some(content_mask), |window| { - for (mut item, ix) in items.into_iter().zip(visible_range.clone()) { - let item_origin = match self.axis { - Axis::Horizontal => { - content_bounds.origin - + point(item_origins[ix] + scroll_offset.x, scroll_offset.y) - } - Axis::Vertical => { - content_bounds.origin - + point(scroll_offset.x, item_origins[ix] + scroll_offset.y) - } - }; - - let available_space = match self.axis { - Axis::Horizontal => size( - AvailableSpace::Definite(item_sizes[ix]), - AvailableSpace::Definite(content_bounds.size.height), - ), - Axis::Vertical => size( - AvailableSpace::Definite(content_bounds.size.width), - AvailableSpace::Definite(item_sizes[ix]), - ), - }; - - item.layout_as_root(available_space, window, cx); - item.prepaint_at(item_origin, window, cx); - layout.items.push(item); - } - }); - } - - hitbox - }, - ) - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - layout: &mut Self::RequestLayoutState, - hitbox: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.base.interactivity().paint( - global_id, - inspector_id, - bounds, - hitbox.as_ref(), - window, - cx, - |_, window, cx| { - for item in &mut layout.items { - item.paint(window, cx); - } - }, - ) - } -} diff --git a/vendor/gpui-component/crates/ui/src/window_border.rs b/vendor/gpui-component/crates/ui/src/window_border.rs deleted file mode 100644 index d996ed67..00000000 --- a/vendor/gpui-component/crates/ui/src/window_border.rs +++ /dev/null @@ -1,238 +0,0 @@ -// From: -// https://github.com/zed-industries/zed/blob/56daba28d40301ee4c05546fadb691d070b7b2b6/crates/gpui/examples/window_shadow.rs -use gpui::{ - AnyElement, App, Bounds, CursorStyle, Decorations, Edges, HitboxBehavior, Hsla, - InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, - ResizeEdge, Size, Styled as _, Window, canvas, div, point, prelude::FluentBuilder as _, px, -}; - -use crate::ActiveTheme; - -#[cfg(not(target_os = "linux"))] -pub(crate) const SHADOW_SIZE: Pixels = px(0.0); -#[cfg(target_os = "linux")] -pub(crate) const SHADOW_SIZE: Pixels = px(12.0); -const BORDER_SIZE: Pixels = px(1.0); -pub(crate) const BORDER_RADIUS: Pixels = px(0.0); - -/// Create a new window border. -pub fn window_border() -> WindowBorder { - WindowBorder::new() -} - -/// Window border use to render a custom window border and shadow for Linux. -#[derive(IntoElement)] -pub struct WindowBorder { - shadow_size: Pixels, - children: Vec, -} - -impl Default for WindowBorder { - fn default() -> Self { - Self { - shadow_size: SHADOW_SIZE, - children: Vec::new(), - } - } -} - -impl WindowBorder { - pub fn new() -> Self { - Self::default() - } - - /// Set the shadow size for typical Linux client-side decorations. - /// - /// Default: [`SHADOW_SIZE`] - pub fn shadow_size(mut self, size: impl Into) -> Self { - self.shadow_size = size.into(); - self - } -} - -/// Get the window paddings. -pub fn window_paddings(window: &Window) -> Edges { - let shadow_size = window.client_inset().unwrap_or(SHADOW_SIZE); - match window.window_decorations() { - Decorations::Server => Edges::all(px(0.0)), - Decorations::Client { tiling } => { - let mut paddings = Edges::all(shadow_size); - if tiling.top { - paddings.top = px(0.0); - } - if tiling.bottom { - paddings.bottom = px(0.0); - } - if tiling.left { - paddings.left = px(0.0); - } - if tiling.right { - paddings.right = px(0.0); - } - paddings - } - } -} - -impl ParentElement for WindowBorder { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl RenderOnce for WindowBorder { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let decorations = window.window_decorations(); - let shadow_size = match decorations { - Decorations::Client { tiling } - if tiling.top && tiling.bottom && tiling.left && tiling.right => - { - px(0.0) - } - _ => self.shadow_size, - }; - window.set_client_inset(shadow_size); - - div() - .id("window-backdrop") - .bg(gpui::transparent_black()) - .map(|div| match decorations { - Decorations::Server => div, - Decorations::Client { tiling, .. } => div - .bg(gpui::transparent_black()) - .child( - canvas( - |_bounds, window, _| { - window.insert_hitbox( - Bounds::new( - point(px(0.0), px(0.0)), - window.window_bounds().get_bounds().size, - ), - HitboxBehavior::Normal, - ) - }, - move |_bounds, hitbox, window, _| { - let mouse = window.mouse_position(); - let size = window.window_bounds().get_bounds().size; - let Decorations::Client { tiling } = window.window_decorations() - else { - return; - }; - if tiling.top && tiling.bottom && tiling.left && tiling.right { - return; - } - let Some(edge) = resize_edge(mouse, shadow_size, size) else { - return; - }; - window.set_cursor_style( - match edge { - ResizeEdge::Top | ResizeEdge::Bottom => { - CursorStyle::ResizeUpDown - } - ResizeEdge::Left | ResizeEdge::Right => { - CursorStyle::ResizeLeftRight - } - ResizeEdge::TopLeft | ResizeEdge::BottomRight => { - CursorStyle::ResizeUpLeftDownRight - } - ResizeEdge::TopRight | ResizeEdge::BottomLeft => { - CursorStyle::ResizeUpRightDownLeft - } - }, - &hitbox, - ); - }, - ) - .size_full() - .absolute(), - ) - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(BORDER_RADIUS) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(BORDER_RADIUS) - }) - .when(!tiling.top, |div| div.pt(shadow_size)) - .when(!tiling.bottom, |div| div.pb(shadow_size)) - .when(!tiling.left, |div| div.pl(shadow_size)) - .when(!tiling.right, |div| div.pr(shadow_size)) - .on_mouse_down(MouseButton::Left, move |_, window, _| { - let Decorations::Client { tiling } = window.window_decorations() else { - return; - }; - if tiling.top && tiling.bottom && tiling.left && tiling.right { - return; - } - let size = window.window_bounds().get_bounds().size; - let pos = window.mouse_position(); - - match resize_edge(pos, shadow_size, size) { - Some(edge) => window.start_window_resize(edge), - None => {} - }; - }), - }) - .size_full() - .child( - div() - .cursor(CursorStyle::default()) - .map(|div| match decorations { - Decorations::Server => div, - Decorations::Client { tiling } => div - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(BORDER_RADIUS) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(BORDER_RADIUS) - }) - .border_color(cx.theme().window_border) - .when(!tiling.top, |div| div.border_t(BORDER_SIZE)) - .when(!tiling.bottom, |div| div.border_b(BORDER_SIZE)) - .when(!tiling.left, |div| div.border_l(BORDER_SIZE)) - .when(!tiling.right, |div| div.border_r(BORDER_SIZE)) - .when(!tiling.is_tiled(), |div| { - div.shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 0.3, - }, - blur_radius: shadow_size / 2., - spread_radius: px(0.), - offset: point(px(0.0), px(0.0)), - }]) - }), - }) - .on_mouse_move(|_e, _, cx| { - cx.stop_propagation(); - }) - .bg(gpui::transparent_black()) - .size_full() - .children(self.children), - ) - } -} - -fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> Option { - let edge = if pos.y < shadow_size && pos.x < shadow_size { - ResizeEdge::TopLeft - } else if pos.y < shadow_size && pos.x > size.width - shadow_size { - ResizeEdge::TopRight - } else if pos.y < shadow_size { - ResizeEdge::Top - } else if pos.y > size.height - shadow_size && pos.x < shadow_size { - ResizeEdge::BottomLeft - } else if pos.y > size.height - shadow_size && pos.x > size.width - shadow_size { - ResizeEdge::BottomRight - } else if pos.y > size.height - shadow_size { - ResizeEdge::Bottom - } else if pos.x < shadow_size { - ResizeEdge::Left - } else if pos.x > size.width - shadow_size { - ResizeEdge::Right - } else { - return None; - }; - Some(edge) -} diff --git a/vendor/gpui-component/crates/ui/src/window_ext.rs b/vendor/gpui-component/crates/ui/src/window_ext.rs deleted file mode 100644 index c1de1ca3..00000000 --- a/vendor/gpui-component/crates/ui/src/window_ext.rs +++ /dev/null @@ -1,204 +0,0 @@ -use crate::{ - Placement, Root, - dialog::{AlertDialog, Dialog}, - input::InputState, - notification::Notification, - sheet::Sheet, -}; -use gpui::{App, ElementId, Entity, Window}; -use std::rc::Rc; - -/// Extension trait for [`Window`] to add dialog, sheet .. functionality. -pub trait WindowExt: Sized { - /// Opens a Sheet at right placement. - fn open_sheet(&mut self, cx: &mut App, build: F) - where - F: Fn(Sheet, &mut Window, &mut App) -> Sheet + 'static; - - /// Opens a Sheet at the given placement. - fn open_sheet_at(&mut self, placement: Placement, cx: &mut App, build: F) - where - F: Fn(Sheet, &mut Window, &mut App) -> Sheet + 'static; - - /// Return true, if there is an active Sheet. - fn has_active_sheet(&mut self, cx: &mut App) -> bool; - - /// Closes the active Sheet. - fn close_sheet(&mut self, cx: &mut App); - - /// Opens a Dialog. - fn open_dialog(&mut self, cx: &mut App, build: F) - where - F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static; - - /// Opens an AlertDialog. - /// - /// This is a convenience method for opening an alert dialog with opinionated defaults. - /// The footer buttons are center-aligned and include an icon based on the variant. - /// - /// # Examples - /// - /// ```ignore - /// use gpui_component::{AlertDialog, alert::AlertVariant}; - /// - /// window.open_alert_dialog(cx, |alert, _, _| { - /// alert.warning() - /// .title("Unsaved Changes") - /// .description("You have unsaved changes. Are you sure you want to leave?") - /// .show_cancel(true) - /// }); - /// ``` - fn open_alert_dialog(&mut self, cx: &mut App, build: F) - where - F: Fn(AlertDialog, &mut Window, &mut App) -> AlertDialog + 'static; - - /// Return true, if there is an active Dialog. - fn has_active_dialog(&mut self, cx: &mut App) -> bool; - - /// Closes the last active Dialog. - fn close_dialog(&mut self, cx: &mut App); - - /// Closes all active Dialogs. - fn close_all_dialogs(&mut self, cx: &mut App); - - /// Pushes a notification to the notification list. - fn push_notification(&mut self, note: impl Into, cx: &mut App); - - /// Removes all notifications whose id matches `T`, including ones registered with - /// either `Notification::id` or `Notification::id1` (any key). - fn remove_notification(&mut self, cx: &mut App); - - /// Removes a single notification matching the given type `T` and `key` (paired with `Notification::id1`). - fn remove_notification1(&mut self, key: impl Into, cx: &mut App); - - /// Clears all notifications. - fn clear_notifications(&mut self, cx: &mut App); - - /// Returns number of notifications. - fn notifications(&mut self, cx: &mut App) -> Rc>>; - - /// Return current focused Input entity. - fn focused_input(&mut self, cx: &mut App) -> Option>; - /// Returns true if there is a focused Input entity. - fn has_focused_input(&mut self, cx: &mut App) -> bool; -} - -impl WindowExt for Window { - #[inline] - fn open_sheet(&mut self, cx: &mut App, build: F) - where - F: Fn(Sheet, &mut Window, &mut App) -> Sheet + 'static, - { - self.open_sheet_at(Placement::Right, cx, build) - } - - #[inline] - fn open_sheet_at(&mut self, placement: Placement, cx: &mut App, build: F) - where - F: Fn(Sheet, &mut Window, &mut App) -> Sheet + 'static, - { - Root::update(self, cx, move |root, window, cx| { - root.open_sheet_at(placement, build, window, cx); - }) - } - - #[inline] - fn has_active_sheet(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).active_sheet.is_some() - } - - #[inline] - fn close_sheet(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.close_sheet(window, cx); - }) - } - - #[inline] - fn open_dialog(&mut self, cx: &mut App, build: F) - where - F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static, - { - Root::update(self, cx, move |root, window, cx| { - root.open_dialog(build, window, cx); - }) - } - - #[inline] - fn open_alert_dialog(&mut self, cx: &mut App, build: F) - where - F: Fn(AlertDialog, &mut Window, &mut App) -> AlertDialog + 'static, - { - self.open_dialog(cx, move |_, window, cx| { - build(AlertDialog::new(cx), window, cx).into_dialog(window, cx) - }) - } - - #[inline] - fn has_active_dialog(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).active_dialogs.len() > 0 - } - - #[inline] - fn close_dialog(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.close_dialog(window, cx); - }) - } - - #[inline] - fn close_all_dialogs(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.close_all_dialogs(window, cx); - }) - } - - #[inline] - fn push_notification(&mut self, note: impl Into, cx: &mut App) { - let note = note.into(); - Root::update(self, cx, |root, window, cx| { - root.push_notification(note, window, cx); - }) - } - - #[inline] - fn remove_notification(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.remove_notification::(window, cx); - }) - } - - #[inline] - fn remove_notification1( - &mut self, - key: impl Into, - cx: &mut App, - ) { - let key = key.into(); - Root::update(self, cx, |root, window, cx| { - root.remove_notification1::(key, window, cx); - }) - } - - #[inline] - fn clear_notifications(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.clear_notifications(window, cx); - }) - } - - #[inline] - fn notifications(&mut self, cx: &mut App) -> Rc>> { - Rc::new(Root::read(self, cx).notification.read(cx).notifications()) - } - - #[inline] - fn has_focused_input(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).focused_input.is_some() - } - - #[inline] - fn focused_input(&mut self, cx: &mut App) -> Option> { - Root::read(self, cx).focused_input.clone() - } -} From 4fdb89d973b929badb1727efb73f9554fb20161d Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sat, 13 Jun 2026 14:12:10 -0300 Subject: [PATCH 02/98] init: entry point --- Cargo.lock | 7 +++++++ Cargo.toml | 3 +++ crates/kosmos/Cargo.toml | 6 ++++++ crates/kosmos/src/main.rs | 3 +++ 4 files changed, 19 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/kosmos/Cargo.toml create mode 100644 crates/kosmos/src/main.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..aa183b33 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "kosmos" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..7381c738 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crates/kosmos"] +resolver = "3" diff --git a/crates/kosmos/Cargo.toml b/crates/kosmos/Cargo.toml new file mode 100644 index 00000000..dda5521f --- /dev/null +++ b/crates/kosmos/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "kosmos" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/kosmos/src/main.rs b/crates/kosmos/src/main.rs new file mode 100644 index 00000000..e7a11a96 --- /dev/null +++ b/crates/kosmos/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From ea28cafe6640e37d1fd01fced5c17c295d496fdd Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sat, 13 Jun 2026 14:22:20 -0300 Subject: [PATCH 03/98] chore: setup crate structure --- Cargo.lock | 8 ++++++++ Cargo.toml | 2 +- crates/kosmos-core/Cargo.toml | 6 ++++++ crates/kosmos-core/src/lib.rs | 14 ++++++++++++++ crates/kosmos-ui/Cargo.toml | 6 ++++++ crates/kosmos-ui/src/lib.rs | 14 ++++++++++++++ 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 crates/kosmos-core/Cargo.toml create mode 100644 crates/kosmos-core/src/lib.rs create mode 100644 crates/kosmos-ui/Cargo.toml create mode 100644 crates/kosmos-ui/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index aa183b33..5c71e207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,11 @@ version = 4 [[package]] name = "kosmos" version = "0.1.0" + +[[package]] +name = "kosmos-core" +version = "0.1.0" + +[[package]] +name = "kosmos-ui" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7381c738..910fe904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/kosmos"] +members = ["crates/kosmos", "crates/kosmos-core", "crates/kosmos-ui"] resolver = "3" diff --git a/crates/kosmos-core/Cargo.toml b/crates/kosmos-core/Cargo.toml new file mode 100644 index 00000000..f11c0d55 --- /dev/null +++ b/crates/kosmos-core/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "kosmos-core" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/kosmos-core/src/lib.rs b/crates/kosmos-core/src/lib.rs new file mode 100644 index 00000000..b93cf3ff --- /dev/null +++ b/crates/kosmos-core/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/crates/kosmos-ui/Cargo.toml b/crates/kosmos-ui/Cargo.toml new file mode 100644 index 00000000..fefc3fc9 --- /dev/null +++ b/crates/kosmos-ui/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "kosmos-ui" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/kosmos-ui/src/lib.rs b/crates/kosmos-ui/src/lib.rs new file mode 100644 index 00000000..b93cf3ff --- /dev/null +++ b/crates/kosmos-ui/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} From 0b49c73020f86a627a16a1d7cf02429c68199f48 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sat, 13 Jun 2026 15:22:57 -0300 Subject: [PATCH 04/98] feat(core): add base tree logic --- Cargo.lock | 688 ++++++++++++++++++++++ crates/kosmos-core/src/lib.rs | 15 +- crates/kosmos-core/src/tree/mod.rs | 7 + crates/kosmos-core/src/tree/panes.rs | 361 ++++++++++++ crates/kosmos-core/src/tree/tabs.rs | 56 ++ crates/kosmos-core/src/tree/workspaces.rs | 435 ++++++++++++++ crates/kosmos-ui/Cargo.toml | 3 + crates/kosmos-ui/src/lib.rs | 17 +- crates/kosmos/Cargo.toml | 1 + crates/kosmos/src/main.rs | 2 +- 10 files changed, 1556 insertions(+), 29 deletions(-) create mode 100644 crates/kosmos-core/src/tree/mod.rs create mode 100644 crates/kosmos-core/src/tree/panes.rs create mode 100644 crates/kosmos-core/src/tree/tabs.rs create mode 100644 crates/kosmos-core/src/tree/workspaces.rs diff --git a/Cargo.lock b/Cargo.lock index 5c71e207..990ed85f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,404 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "kosmos" version = "0.1.0" +dependencies = [ + "kosmos-ui", +] [[package]] name = "kosmos-core" @@ -13,3 +408,296 @@ version = "0.1.0" [[package]] name = "kosmos-ui" version = "0.1.0" +dependencies = [ + "gtk4", + "kosmos-core", + "libadwaita", +] + +[[package]] +name = "libadwaita" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] diff --git a/crates/kosmos-core/src/lib.rs b/crates/kosmos-core/src/lib.rs index b93cf3ff..87821cc7 100644 --- a/crates/kosmos-core/src/lib.rs +++ b/crates/kosmos-core/src/lib.rs @@ -1,14 +1 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod tree; diff --git a/crates/kosmos-core/src/tree/mod.rs b/crates/kosmos-core/src/tree/mod.rs new file mode 100644 index 00000000..d6cf1344 --- /dev/null +++ b/crates/kosmos-core/src/tree/mod.rs @@ -0,0 +1,7 @@ +mod panes; +mod tabs; +mod workspaces; + +pub use panes::{Pane, PaneId, PaneNode, SplitAxis, SplitPane}; +pub use tabs::{Tab, TabId, TabKind}; +pub use workspaces::{Workspace, WorkspaceId, WorkspaceList}; diff --git a/crates/kosmos-core/src/tree/panes.rs b/crates/kosmos-core/src/tree/panes.rs new file mode 100644 index 00000000..42ce8da8 --- /dev/null +++ b/crates/kosmos-core/src/tree/panes.rs @@ -0,0 +1,361 @@ +use super::tabs::{Tab, TabId}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PaneId(u64); + +impl PaneId { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PaneNode { + Leaf(Pane), + Split(SplitPane), +} + +impl PaneNode { + pub fn leaf(pane: Pane) -> Self { + Self::Leaf(pane) + } + + pub fn split(axis: SplitAxis, ratio: f32, first: PaneNode, second: PaneNode) -> Self { + Self::Split(SplitPane::new(axis, ratio, first, second)) + } + + pub fn find_pane(&self, pane_id: PaneId) -> Option<&Pane> { + match self { + Self::Leaf(pane) if pane.id == pane_id => Some(pane), + Self::Leaf(_) => None, + Self::Split(split) => split + .first() + .find_pane(pane_id) + .or_else(|| split.second().find_pane(pane_id)), + } + } + + pub fn find_pane_mut(&mut self, pane_id: PaneId) -> Option<&mut Pane> { + match self { + Self::Leaf(pane) if pane.id == pane_id => Some(pane), + Self::Leaf(_) => None, + Self::Split(split) => { + if split.first().contains_pane(pane_id) { + split.first_mut().find_pane_mut(pane_id) + } else { + split.second_mut().find_pane_mut(pane_id) + } + } + } + } + + pub fn contains_pane(&self, pane_id: PaneId) -> bool { + self.find_pane(pane_id).is_some() + } + + pub fn first_pane_id(&self) -> PaneId { + match self { + Self::Leaf(pane) => pane.id(), + Self::Split(split) => split.first().first_pane_id(), + } + } + + pub fn pane_count(&self) -> usize { + match self { + Self::Leaf(_) => 1, + Self::Split(split) => split.first().pane_count() + split.second().pane_count(), + } + } + + pub fn split_pane( + &mut self, + pane_id: PaneId, + axis: SplitAxis, + new_pane: Pane, + ratio: f32, + ) -> bool { + validate_split_ratio(ratio); + + match self { + Self::Leaf(pane) if pane.id == pane_id => { + let existing_pane = Pane { + id: pane.id, + tabs: std::mem::take(&mut pane.tabs), + active_tab: pane.active_tab, + }; + + *self = Self::split(axis, ratio, Self::Leaf(existing_pane), Self::Leaf(new_pane)); + + true + } + Self::Leaf(_) => false, + Self::Split(split) => { + if split.first().contains_pane(pane_id) { + split.first_mut().split_pane(pane_id, axis, new_pane, ratio) + } else { + split + .second_mut() + .split_pane(pane_id, axis, new_pane, ratio) + } + } + } + } + + pub(crate) fn remove_pane(self, pane_id: PaneId) -> (Option, Option) { + match self { + Self::Leaf(pane) if pane.id == pane_id => (None, Some(pane)), + Self::Leaf(pane) => (Some(Self::Leaf(pane)), None), + Self::Split(split) => remove_pane_from_split(split, pane_id), + } + } +} + +fn remove_pane_from_split(split: SplitPane, pane_id: PaneId) -> (Option, Option) { + let SplitPane { + axis, + ratio, + first, + second, + } = split; + + let (first_node, removed_pane) = (*first).remove_pane(pane_id); + if let Some(removed_pane) = removed_pane { + let node = match first_node { + Some(first_node) => PaneNode::split(axis, ratio, first_node, *second), + None => *second, + }; + + return (Some(node), Some(removed_pane)); + } + + let first_node = first_node.expect("pane removal without a removed pane must keep the node"); + let (second_node, removed_pane) = (*second).remove_pane(pane_id); + if let Some(removed_pane) = removed_pane { + let node = match second_node { + Some(second_node) => PaneNode::split(axis, ratio, first_node, second_node), + None => first_node, + }; + + return (Some(node), Some(removed_pane)); + } + + let second_node = second_node.expect("pane removal without a removed pane must keep the node"); + + ( + Some(PaneNode::split(axis, ratio, first_node, second_node)), + None, + ) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SplitPane { + axis: SplitAxis, + ratio: f32, + first: Box, + second: Box, +} + +impl SplitPane { + pub fn new(axis: SplitAxis, ratio: f32, first: PaneNode, second: PaneNode) -> Self { + validate_split_ratio(ratio); + + Self { + axis, + ratio, + first: Box::new(first), + second: Box::new(second), + } + } + + pub fn axis(&self) -> SplitAxis { + self.axis + } + + pub fn ratio(&self) -> f32 { + self.ratio + } + + pub fn first(&self) -> &PaneNode { + &self.first + } + + pub fn first_mut(&mut self) -> &mut PaneNode { + &mut self.first + } + + pub fn second(&self) -> &PaneNode { + &self.second + } + + pub fn second_mut(&mut self) -> &mut PaneNode { + &mut self.second + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SplitAxis { + Horizontal, + Vertical, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Pane { + id: PaneId, + tabs: Vec, + active_tab: Option, +} + +impl Pane { + pub fn new(id: PaneId, initial_tab: Tab) -> Self { + let active_tab = initial_tab.id(); + + Self { + id, + tabs: vec![initial_tab], + active_tab: Some(active_tab), + } + } + + pub fn with_tab(id: PaneId, tab: Tab) -> Self { + Self::new(id, tab) + } + + pub fn id(&self) -> PaneId { + self.id + } + + pub fn tabs(&self) -> &[Tab] { + &self.tabs + } + + pub fn active_tab_id(&self) -> TabId { + self.active_tab + .expect("a pane exposed by kosmos-core must always have an active tab") + } + + pub fn active_tab(&self) -> &Tab { + let active_tab_id = self.active_tab_id(); + + self.tabs + .iter() + .find(|tab| tab.id() == active_tab_id) + .expect("a pane exposed by kosmos-core must contain its active tab") + } + + pub fn add_tab(&mut self, tab: Tab) { + let tab_id = tab.id(); + self.tabs.push(tab); + + if self.active_tab.is_none() { + self.active_tab = Some(tab_id); + } + } + + pub fn activate_tab(&mut self, tab_id: TabId) -> bool { + if self.contains_tab(tab_id) { + self.active_tab = Some(tab_id); + true + } else { + false + } + } + + pub(crate) fn remove_tab(&mut self, tab_id: TabId) -> Option { + let tab_index = self.tabs.iter().position(|tab| tab.id() == tab_id)?; + let removed_tab = self.tabs.remove(tab_index); + + if self.active_tab == Some(tab_id) { + self.active_tab = self + .tabs + .get(tab_index) + .or_else(|| self.tabs.last()) + .map(Tab::id); + } + + Some(removed_tab) + } + + pub(crate) fn is_empty(&self) -> bool { + self.tabs.is_empty() + } + + pub fn contains_tab(&self, tab_id: TabId) -> bool { + self.tabs.iter().any(|tab| tab.id() == tab_id) + } +} + +fn validate_split_ratio(ratio: f32) { + assert!( + ratio.is_finite() && ratio > 0.0 && ratio < 1.0, + "split ratio must be a finite value between 0.0 and 1.0" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tree::{Tab, TabKind}; + + #[test] + fn removing_active_tab_moves_focus_to_neighbor() { + let first_tab = Tab::new(TabId::new(1), "First", TabKind::Blank); + let second_tab = Tab::new(TabId::new(2), "Second", TabKind::Blank); + let mut pane = Pane::with_tab(PaneId::new(1), first_tab); + + pane.add_tab(second_tab); + pane.activate_tab(TabId::new(1)); + + let removed = pane.remove_tab(TabId::new(1)); + + assert_eq!(removed.map(|tab| tab.id()), Some(TabId::new(1))); + assert_eq!(pane.active_tab_id(), TabId::new(2)); + } + + #[test] + fn splitting_leaf_creates_recursive_node() { + let first_pane = Pane::new( + PaneId::new(1), + Tab::new(TabId::new(1), "First", TabKind::Blank), + ); + let second_pane = Pane::new( + PaneId::new(2), + Tab::new(TabId::new(2), "Second", TabKind::Blank), + ); + let mut root = PaneNode::leaf(first_pane); + + assert!(root.split_pane(PaneId::new(1), SplitAxis::Horizontal, second_pane, 0.5)); + + assert!(root.contains_pane(PaneId::new(1))); + assert!(root.contains_pane(PaneId::new(2))); + } + + #[test] + fn removing_pane_collapses_split_to_sibling() { + let first_pane = Pane::new( + PaneId::new(1), + Tab::new(TabId::new(1), "First", TabKind::Blank), + ); + let second_pane = Pane::new( + PaneId::new(2), + Tab::new(TabId::new(2), "Second", TabKind::Blank), + ); + let root = PaneNode::split( + SplitAxis::Horizontal, + 0.5, + PaneNode::leaf(first_pane), + PaneNode::leaf(second_pane), + ); + + let (root, removed_pane) = root.remove_pane(PaneId::new(1)); + let root = root.expect("removing one pane from a split must keep its sibling"); + + assert_eq!(removed_pane.map(|pane| pane.id()), Some(PaneId::new(1))); + assert!(!root.contains_pane(PaneId::new(1))); + assert!(root.contains_pane(PaneId::new(2))); + assert_eq!(root.pane_count(), 1); + } +} diff --git a/crates/kosmos-core/src/tree/tabs.rs b/crates/kosmos-core/src/tree/tabs.rs new file mode 100644 index 00000000..64687e27 --- /dev/null +++ b/crates/kosmos-core/src/tree/tabs.rs @@ -0,0 +1,56 @@ +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct TabId(u64); + +impl TabId { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Tab { + id: TabId, + title: String, + kind: TabKind, +} + +impl Tab { + pub fn new(id: TabId, title: impl Into, kind: TabKind) -> Self { + Self { + id, + title: title.into(), + kind, + } + } + + pub fn id(&self) -> TabId { + self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn rename(&mut self, title: impl Into) { + self.title = title.into(); + } + + pub fn kind(&self) -> &TabKind { + &self.kind + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TabKind { + Blank, + FileTree, + Editor, + Git, + Search, + Terminal, + Settings, +} diff --git a/crates/kosmos-core/src/tree/workspaces.rs b/crates/kosmos-core/src/tree/workspaces.rs new file mode 100644 index 00000000..9f530d91 --- /dev/null +++ b/crates/kosmos-core/src/tree/workspaces.rs @@ -0,0 +1,435 @@ +use std::path::{Path, PathBuf}; + +use super::panes::{Pane, PaneId, PaneNode, SplitAxis}; +use super::tabs::{Tab, TabId}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct WorkspaceId(u64); + +impl WorkspaceId { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Workspace { + id: WorkspaceId, + name: String, + directory: PathBuf, + root: PaneNode, + active_pane: PaneId, +} + +impl Workspace { + pub fn new(id: WorkspaceId, directory: impl Into, initial_pane: Pane) -> Self { + let active_pane = initial_pane.id(); + let directory = directory.into(); + let name = workspace_name(&directory); + + Self { + id, + name, + directory, + root: PaneNode::leaf(initial_pane), + active_pane, + } + } + + pub fn from_root( + id: WorkspaceId, + directory: impl Into, + root: PaneNode, + active_pane: PaneId, + ) -> Option { + root.contains_pane(active_pane).then(|| { + let directory = directory.into(); + + Self { + id, + name: workspace_name(&directory), + directory, + root, + active_pane, + } + }) + } + + pub fn id(&self) -> WorkspaceId { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn rename(&mut self, name: impl Into) { + self.name = name.into(); + } + + pub fn root(&self) -> &PaneNode { + &self.root + } + + pub fn root_mut(&mut self) -> &mut PaneNode { + &mut self.root + } + + pub fn active_pane_id(&self) -> PaneId { + self.active_pane + } + + pub fn active_pane(&self) -> Option<&Pane> { + self.root.find_pane(self.active_pane) + } + + pub fn active_pane_mut(&mut self) -> Option<&mut Pane> { + self.root.find_pane_mut(self.active_pane) + } + + pub fn activate_pane(&mut self, pane_id: PaneId) -> bool { + if self.root.contains_pane(pane_id) { + self.active_pane = pane_id; + true + } else { + false + } + } + + pub fn split_active_pane(&mut self, axis: SplitAxis, new_pane: Pane, ratio: f32) -> bool { + let new_pane_id = new_pane.id(); + + if self + .root + .split_pane(self.active_pane, axis, new_pane, ratio) + { + self.active_pane = new_pane_id; + true + } else { + false + } + } + + pub fn add_tab_to_active_pane(&mut self, tab: Tab) -> bool { + if let Some(pane) = self.active_pane_mut() { + pane.add_tab(tab); + true + } else { + false + } + } + + pub fn add_tab_to_pane(&mut self, pane_id: PaneId, tab: Tab) -> bool { + if let Some(pane) = self.root.find_pane_mut(pane_id) { + pane.add_tab(tab); + true + } else { + false + } + } + + pub fn activate_tab_in_active_pane(&mut self, tab_id: TabId) -> bool { + self.active_pane_mut() + .is_some_and(|pane| pane.activate_tab(tab_id)) + } + + pub fn activate_tab(&mut self, pane_id: PaneId, tab_id: TabId) -> bool { + if let Some(pane) = self.root.find_pane_mut(pane_id) { + self.active_pane = pane_id; + pane.activate_tab(tab_id) + } else { + false + } + } + + pub fn close_tab( + &mut self, + pane_id: PaneId, + tab_id: TabId, + fallback_pane: Pane, + ) -> Option { + let pane = self.root.find_pane_mut(pane_id)?; + let removed_tab = pane.remove_tab(tab_id)?; + + if !pane.is_empty() { + return Some(removed_tab); + } + + self.remove_empty_pane(pane_id, fallback_pane); + + Some(removed_tab) + } + + fn remove_empty_pane(&mut self, pane_id: PaneId, fallback_pane: Pane) { + let fallback_pane_id = fallback_pane.id(); + let old_root = std::mem::replace(&mut self.root, PaneNode::leaf(fallback_pane)); + let (root, removed_pane) = old_root.remove_pane(pane_id); + + debug_assert!(removed_pane.is_some()); + + if let Some(root) = root { + self.active_pane = root.first_pane_id(); + self.root = root; + } else { + self.active_pane = fallback_pane_id; + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WorkspaceList { + workspaces: Vec, + active_workspace: Option, +} + +impl WorkspaceList { + pub fn new() -> Self { + Self::default() + } + + pub fn workspaces(&self) -> &[Workspace] { + &self.workspaces + } + + pub fn is_empty(&self) -> bool { + self.workspaces.is_empty() + } + + pub fn active_workspace_id(&self) -> Option { + self.active_workspace + } + + pub fn active_workspace(&self) -> Option<&Workspace> { + let active_workspace = self.active_workspace?; + + self.workspace(active_workspace) + } + + pub fn active_workspace_mut(&mut self) -> Option<&mut Workspace> { + let active_workspace = self.active_workspace?; + + self.workspace_mut(active_workspace) + } + + pub fn workspace(&self, workspace_id: WorkspaceId) -> Option<&Workspace> { + self.workspaces + .iter() + .find(|workspace| workspace.id() == workspace_id) + } + + pub fn workspace_mut(&mut self, workspace_id: WorkspaceId) -> Option<&mut Workspace> { + self.workspaces + .iter_mut() + .find(|workspace| workspace.id() == workspace_id) + } + + pub fn add_workspace(&mut self, workspace: Workspace) -> bool { + let workspace_id = workspace.id(); + + if self.workspace(workspace_id).is_some() { + return false; + } + + self.workspaces.push(workspace); + self.active_workspace = Some(workspace_id); + + true + } + + pub fn activate_workspace(&mut self, workspace_id: WorkspaceId) -> bool { + if self.workspace(workspace_id).is_none() { + return false; + } + + self.active_workspace = Some(workspace_id); + + true + } + + pub fn close_active_workspace(&mut self) -> Option { + let active_workspace = self.active_workspace?; + + self.close_workspace(active_workspace) + } + + pub fn close_workspace(&mut self, workspace_id: WorkspaceId) -> Option { + let workspace_index = self + .workspaces + .iter() + .position(|workspace| workspace.id() == workspace_id)?; + let removed_workspace = self.workspaces.remove(workspace_index); + + if self.active_workspace == Some(workspace_id) { + self.active_workspace = next_workspace_id(&self.workspaces, workspace_index); + } + + Some(removed_workspace) + } +} + +fn workspace_name(directory: &Path) -> String { + directory + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .unwrap_or_else(|| directory.display().to_string()) +} + +fn next_workspace_id( + workspaces: &[Workspace], + closed_workspace_index: usize, +) -> Option { + workspaces + .get(closed_workspace_index) + .or_else(|| { + closed_workspace_index + .checked_sub(1) + .and_then(|previous_index| workspaces.get(previous_index)) + }) + .map(Workspace::id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tree::TabKind; + use std::path::Path; + + fn pane(pane_id: u64, tab_id: u64) -> Pane { + Pane::new( + PaneId::new(pane_id), + Tab::new(TabId::new(tab_id), "Blank", TabKind::Blank), + ) + } + + fn workspace(workspace_id: u64, pane_id: u64, tab_id: u64) -> Workspace { + Workspace::new( + WorkspaceId::new(workspace_id), + format!("/workspaces/workspace-{workspace_id}"), + pane(pane_id, tab_id), + ) + } + + #[test] + fn workspace_starts_with_initial_pane_active() { + let workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + + assert_eq!(workspace.active_pane_id(), PaneId::new(1)); + assert!(workspace.active_pane().is_some()); + } + + #[test] + fn workspace_is_set_on_a_directory() { + let workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + + assert_eq!(workspace.name(), "main"); + assert_eq!(workspace.directory(), Path::new("/workspaces/main")); + } + + #[test] + fn splitting_active_pane_focuses_new_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + + assert!(workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5)); + + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + assert!(workspace.root().contains_pane(PaneId::new(1))); + assert!(workspace.root().contains_pane(PaneId::new(2))); + } + + #[test] + fn closing_last_tab_removes_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + + let removed_tab = workspace.close_tab(PaneId::new(2), TabId::new(2), pane(3, 3)); + + assert_eq!(removed_tab.map(|tab| tab.id()), Some(TabId::new(2))); + assert!(!workspace.root().contains_pane(PaneId::new(2))); + assert!(workspace.root().contains_pane(PaneId::new(1))); + assert_eq!(workspace.root().pane_count(), 1); + } + + #[test] + fn closing_last_workspace_tab_respawns_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + + let removed_tab = workspace.close_tab(PaneId::new(1), TabId::new(1), pane(2, 2)); + + assert_eq!(removed_tab.map(|tab| tab.id()), Some(TabId::new(1))); + assert!(!workspace.root().contains_pane(PaneId::new(1))); + assert!(workspace.root().contains_pane(PaneId::new(2))); + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + assert_eq!(workspace.root().pane_count(), 1); + } + + #[test] + fn workspace_list_can_be_empty() { + let workspaces = WorkspaceList::new(); + + assert!(workspaces.is_empty()); + assert_eq!(workspaces.active_workspace_id(), None); + assert!(workspaces.active_workspace().is_none()); + } + + #[test] + fn adding_workspace_activates_it() { + let mut workspaces = WorkspaceList::new(); + + assert!(workspaces.add_workspace(workspace(1, 1, 1))); + + assert_eq!(workspaces.active_workspace_id(), Some(WorkspaceId::new(1))); + assert_eq!( + workspaces.active_workspace().map(Workspace::id), + Some(WorkspaceId::new(1)) + ); + } + + #[test] + fn workspace_list_rejects_duplicate_workspace_ids() { + let mut workspaces = WorkspaceList::new(); + + assert!(workspaces.add_workspace(workspace(1, 1, 1))); + assert!(!workspaces.add_workspace(workspace(1, 2, 2))); + + assert_eq!(workspaces.workspaces().len(), 1); + } + + #[test] + fn closing_active_workspace_activates_neighbor() { + let mut workspaces = WorkspaceList::new(); + workspaces.add_workspace(workspace(1, 1, 1)); + workspaces.add_workspace(workspace(2, 2, 2)); + + let closed_workspace = workspaces.close_active_workspace(); + + assert_eq!( + closed_workspace.map(|workspace| workspace.id()), + Some(WorkspaceId::new(2)) + ); + assert_eq!(workspaces.active_workspace_id(), Some(WorkspaceId::new(1))); + } + + #[test] + fn closing_last_workspace_leaves_workspace_list_empty() { + let mut workspaces = WorkspaceList::new(); + workspaces.add_workspace(workspace(1, 1, 1)); + + let closed_workspace = workspaces.close_workspace(WorkspaceId::new(1)); + + assert_eq!( + closed_workspace.map(|workspace| workspace.id()), + Some(WorkspaceId::new(1)) + ); + assert!(workspaces.is_empty()); + assert_eq!(workspaces.active_workspace_id(), None); + } +} diff --git a/crates/kosmos-ui/Cargo.toml b/crates/kosmos-ui/Cargo.toml index fefc3fc9..4b4cef45 100644 --- a/crates/kosmos-ui/Cargo.toml +++ b/crates/kosmos-ui/Cargo.toml @@ -4,3 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] +adw = { version = "0.9.1", package = "libadwaita", features = ["v1_5"] } +gtk = { version = "0.11.3", package = "gtk4", features = ["v4_10"] } +kosmos-core = { version = "0.1.0", path = "../kosmos-core" } diff --git a/crates/kosmos-ui/src/lib.rs b/crates/kosmos-ui/src/lib.rs index b93cf3ff..1d2d2267 100644 --- a/crates/kosmos-ui/src/lib.rs +++ b/crates/kosmos-ui/src/lib.rs @@ -1,14 +1,3 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub fn main() { + println!("hello ui"); +} \ No newline at end of file diff --git a/crates/kosmos/Cargo.toml b/crates/kosmos/Cargo.toml index dda5521f..941955a5 100644 --- a/crates/kosmos/Cargo.toml +++ b/crates/kosmos/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] +kosmos-ui = { version = "0.1.0", path = "../kosmos-ui" } diff --git a/crates/kosmos/src/main.rs b/crates/kosmos/src/main.rs index e7a11a96..ad379d6e 100644 --- a/crates/kosmos/src/main.rs +++ b/crates/kosmos/src/main.rs @@ -1,3 +1,3 @@ fn main() { - println!("Hello, world!"); + println!("hello world"); } From 6c167baaf9ce5e0360ad1aa4b8ae87d28d63b05a Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 14 Jun 2026 00:41:06 -0300 Subject: [PATCH 05/98] chore: restructure with rust backend and qt frontend with ipc communication --- .gitignore | 2 + Cargo.lock | 639 +----------------- Cargo.toml | 2 +- README.md | 15 + {crates/kosmos-core => core}/Cargo.toml | 2 +- core/src/lib.rs | 4 + core/src/state.rs | 228 +++++++ {crates/kosmos-core => core}/src/tree/mod.rs | 0 .../kosmos-core => core}/src/tree/panes.rs | 116 +++- {crates/kosmos-core => core}/src/tree/tabs.rs | 0 .../src/tree/workspaces.rs | 41 +- crates/kosmos-core/src/lib.rs | 1 - crates/kosmos-ui/Cargo.toml | 9 - crates/kosmos-ui/src/lib.rs | 3 - crates/kosmos/Cargo.toml | 7 - crates/kosmos/src/main.rs | 3 - scripts/run.sh | 47 ++ server/Cargo.toml | 9 + server/src/ipc/messages/envelope.rs | 77 +++ server/src/ipc/messages/mod.rs | 4 + server/src/ipc/messages/pane.rs | 111 +++ server/src/ipc/messages/tab.rs | 106 +++ server/src/ipc/messages/workspace.rs | 64 ++ server/src/ipc/mod.rs | 5 + server/src/ipc/router/mod.rs | 53 ++ server/src/ipc/router/pane.rs | 42 ++ server/src/ipc/router/tab.rs | 87 +++ server/src/ipc/router/workspace.rs | 55 ++ server/src/ipc/transport/codec.rs | 24 + server/src/ipc/transport/connection.rs | 35 + server/src/ipc/transport/mod.rs | 3 + server/src/ipc/transport/server.rs | 40 ++ server/src/main.rs | 19 + ui/CMakeLists.txt | 17 + ui/src/ipc/IpcClient.cpp | 66 ++ ui/src/ipc/IpcClient.h | 28 + ui/src/main.cpp | 80 +++ 37 files changed, 1395 insertions(+), 649 deletions(-) rename {crates/kosmos-core => core}/Cargo.toml (74%) create mode 100644 core/src/lib.rs create mode 100644 core/src/state.rs rename {crates/kosmos-core => core}/src/tree/mod.rs (100%) rename {crates/kosmos-core => core}/src/tree/panes.rs (72%) rename {crates/kosmos-core => core}/src/tree/tabs.rs (100%) rename {crates/kosmos-core => core}/src/tree/workspaces.rs (92%) delete mode 100644 crates/kosmos-core/src/lib.rs delete mode 100644 crates/kosmos-ui/Cargo.toml delete mode 100644 crates/kosmos-ui/src/lib.rs delete mode 100644 crates/kosmos/Cargo.toml delete mode 100644 crates/kosmos/src/main.rs create mode 100755 scripts/run.sh create mode 100644 server/Cargo.toml create mode 100644 server/src/ipc/messages/envelope.rs create mode 100644 server/src/ipc/messages/mod.rs create mode 100644 server/src/ipc/messages/pane.rs create mode 100644 server/src/ipc/messages/tab.rs create mode 100644 server/src/ipc/messages/workspace.rs create mode 100644 server/src/ipc/mod.rs create mode 100644 server/src/ipc/router/mod.rs create mode 100644 server/src/ipc/router/pane.rs create mode 100644 server/src/ipc/router/tab.rs create mode 100644 server/src/ipc/router/workspace.rs create mode 100644 server/src/ipc/transport/codec.rs create mode 100644 server/src/ipc/transport/connection.rs create mode 100644 server/src/ipc/transport/mod.rs create mode 100644 server/src/ipc/transport/server.rs create mode 100644 server/src/main.rs create mode 100644 ui/CMakeLists.txt create mode 100644 ui/src/ipc/IpcClient.cpp create mode 100644 ui/src/ipc/IpcClient.h create mode 100644 ui/src/main.cpp diff --git a/.gitignore b/.gitignore index 0aec4f64..639d18c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target +/core/target +/ui/build .claude .codex diff --git a/Cargo.lock b/Cargo.lock index 990ed85f..e54cb861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,514 +3,30 @@ version = 4 [[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "cairo-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" -dependencies = [ - "bitflags", - "cairo-sys-rs", - "glib", - "libc", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "cfg-expr" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk4" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk4-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk4-sys" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gio" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "pin-project-lite", - "smallvec", -] - -[[package]] -name = "gio-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "windows-sys", -] - -[[package]] -name = "glib" -version = "0.22.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" -dependencies = [ - "bitflags", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "smallvec", -] - -[[package]] -name = "glib-macros" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "glib-sys" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "gobject-sys" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "graphene-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" -dependencies = [ - "glib", - "graphene-sys", - "libc", -] - -[[package]] -name = "graphene-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" -dependencies = [ - "glib-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gsk4" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" -dependencies = [ - "cairo-rs", - "gdk4", - "glib", - "graphene-rs", - "gsk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gsk4-sys" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" -dependencies = [ - "cairo-sys-rs", - "gdk4-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk4" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" -dependencies = [ - "cairo-rs", - "field-offset", - "futures-channel", - "gdk-pixbuf", - "gdk4", - "gio", - "glib", - "graphene-rs", - "gsk4", - "gtk4-macros", - "gtk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gtk4-macros" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gtk4-sys" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "gsk4-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "kosmos" +name = "core" version = "0.1.0" -dependencies = [ - "kosmos-ui", -] [[package]] -name = "kosmos-core" -version = "0.1.0" - -[[package]] -name = "kosmos-ui" -version = "0.1.0" -dependencies = [ - "gtk4", - "kosmos-core", - "libadwaita", -] - -[[package]] -name = "libadwaita" -version = "0.9.1" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" -dependencies = [ - "gdk4", - "gio", - "glib", - "gtk4", - "libadwaita-sys", - "libc", - "pango", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "libadwaita-sys" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" +name = "kosmos-server" +version = "0.1.0" dependencies = [ - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "libc", - "pango-sys", - "system-deps", + "core", + "serde", + "serde_json", ] -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "pango" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" -dependencies = [ - "gio", - "glib", - "libc", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -530,20 +46,15 @@ dependencies = [ ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "semver", + "serde_core", + "serde_derive", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde_core" version = "1.0.228" @@ -565,26 +76,18 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "serde_json" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "itoa", + "memchr", + "serde", "serde_core", + "zmij", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - [[package]] name = "syn" version = "2.0.117" @@ -596,76 +99,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "system-deps" -version = "7.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml", - "version-compare", -] - -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -673,31 +106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 910fe904..9a9f9e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/kosmos", "crates/kosmos-core", "crates/kosmos-ui"] +members = ["core", "server"] resolver = "3" diff --git a/README.md b/README.md index 2d1aa55f..88ba49cf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,21 @@ Most editors dictate where things go. Kosmos lets you treat every view as a tab Kosmos only runs on Linux. Windows and macOS are not supported, and support for those platforms is not planned at this time. +## Project layout + +- `core/` contains the Rust logic layer. +- `server/` contains the Rust backend process that imports `core`. +- `ui/` contains the Qt frontend. +- `assets/` contains shared branding and icon assets. + +## Development + +- Run the app with `./scripts/run.sh`. +- Test the Rust workspace with `cargo test --workspace`. +- Build the Qt UI with `cmake -S ui -B ui/build` and `cmake --build ui/build`. + +The server and UI communicate over a Unix socket. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. + ## License MIT - see [LICENSE](LICENSE). diff --git a/crates/kosmos-core/Cargo.toml b/core/Cargo.toml similarity index 74% rename from crates/kosmos-core/Cargo.toml rename to core/Cargo.toml index f11c0d55..143436c4 100644 --- a/crates/kosmos-core/Cargo.toml +++ b/core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "kosmos-core" +name = "core" version = "0.1.0" edition = "2024" diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 00000000..72a69e48 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,4 @@ +mod state; + +pub use state::State; +pub mod tree; diff --git a/core/src/state.rs b/core/src/state.rs new file mode 100644 index 00000000..aa57429e --- /dev/null +++ b/core/src/state.rs @@ -0,0 +1,228 @@ +use std::path::PathBuf; + +use crate::tree::{ + Pane, PaneId, SplitAxis, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, +}; + +#[derive(Debug)] +pub struct State { + workspaces: WorkspaceList, + next_workspace_id: u64, + next_pane_id: u64, + next_tab_id: u64, +} + +impl State { + pub fn new() -> Self { + Self::default() + } + + pub fn workspaces(&self) -> &WorkspaceList { + &self.workspaces + } + + pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { + let workspace_id = self.next_workspace_id(); + let initial_pane = self.blank_pane(); + let workspace = Workspace::new(workspace_id, directory, initial_pane); + + self.workspaces.add_workspace(workspace); + + workspace_id + } + + pub fn activate_workspace(&mut self, workspace_id: WorkspaceId) -> bool { + self.workspaces.activate_workspace(workspace_id) + } + + pub fn close_workspace(&mut self, workspace_id: Option) -> Option { + match workspace_id { + Some(workspace_id) => self.workspaces.close_workspace(workspace_id), + None => self.workspaces.close_active_workspace(), + } + } + + pub fn split_pane( + &mut self, + workspace_id: Option, + pane_id: Option, + axis: SplitAxis, + new_pane_first: bool, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let new_pane = self.blank_pane(); + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + let pane_id = pane_id.unwrap_or_else(|| workspace.active_pane_id()); + + workspace.split_pane_with_new_pane_first(pane_id, axis, new_pane, 0.5, new_pane_first) + } + + pub fn activate_pane(&mut self, workspace_id: Option, pane_id: PaneId) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.activate_pane(pane_id) + } + + pub fn open_tab( + &mut self, + workspace_id: Option, + pane_id: Option, + title: impl Into, + kind: TabKind, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let tab = self.next_tab(title, kind); + let tab_id = tab.id(); + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + let pane_id = pane_id.unwrap_or_else(|| workspace.active_pane_id()); + + if workspace.add_tab_to_pane(pane_id, tab) { + workspace.activate_tab(pane_id, tab_id); + true + } else { + false + } + } + + pub fn activate_tab( + &mut self, + workspace_id: Option, + pane_id: PaneId, + tab_id: TabId, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.activate_tab(pane_id, tab_id) + } + + pub fn close_tab( + &mut self, + workspace_id: Option, + pane_id: PaneId, + tab_id: TabId, + ) -> Option { + let workspace_id = self.resolve_workspace_id(workspace_id)?; + let fallback_pane = self.blank_pane(); + let workspace = self.workspace_mut(workspace_id)?; + + workspace.close_tab(pane_id, tab_id, fallback_pane) + } + + pub fn reorder_tab( + &mut self, + workspace_id: Option, + pane_id: PaneId, + tab_id: TabId, + target_index: usize, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.reorder_tab_in_pane(pane_id, tab_id, target_index) + } + + fn workspace_mut(&mut self, workspace_id: WorkspaceId) -> Option<&mut Workspace> { + self.workspaces.workspace_mut(workspace_id) + } + + fn resolve_workspace_id(&self, workspace_id: Option) -> Option { + workspace_id.or_else(|| self.workspaces.active_workspace_id()) + } + + fn next_workspace_id(&mut self) -> WorkspaceId { + let workspace_id = WorkspaceId::new(self.next_workspace_id); + self.next_workspace_id += 1; + workspace_id + } + + fn next_pane_id(&mut self) -> PaneId { + let pane_id = PaneId::new(self.next_pane_id); + self.next_pane_id += 1; + pane_id + } + + fn next_tab_id(&mut self) -> TabId { + let tab_id = TabId::new(self.next_tab_id); + self.next_tab_id += 1; + tab_id + } + + fn blank_pane(&mut self) -> Pane { + let pane_id = self.next_pane_id(); + let tab = self.next_tab("Blank", TabKind::Blank); + + Pane::new(pane_id, tab) + } + + fn next_tab(&mut self, title: impl Into, kind: TabKind) -> Tab { + Tab::new(self.next_tab_id(), title, kind) + } +} + +impl Default for State { + fn default() -> Self { + Self { + workspaces: WorkspaceList::new(), + next_workspace_id: 1, + next_pane_id: 1, + next_tab_id: 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opening_workspace_creates_active_workspace() { + let mut state = State::new(); + + let workspace_id = state.open_workspace("/workspaces/main"); + + assert_eq!(workspace_id, WorkspaceId::new(1)); + assert_eq!(state.workspaces().active_workspace_id(), Some(workspace_id)); + assert_eq!(state.workspaces().workspaces().len(), 1); + } + + #[test] + fn opening_tab_adds_it_to_active_pane() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + + assert!(state.open_tab(None, None, "Search", TabKind::Search)); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let pane = workspace + .active_pane() + .expect("workspace should have an active pane"); + + assert_eq!(pane.tabs().len(), 2); + assert_eq!(pane.active_tab().title(), "Search"); + } +} diff --git a/crates/kosmos-core/src/tree/mod.rs b/core/src/tree/mod.rs similarity index 100% rename from crates/kosmos-core/src/tree/mod.rs rename to core/src/tree/mod.rs diff --git a/crates/kosmos-core/src/tree/panes.rs b/core/src/tree/panes.rs similarity index 72% rename from crates/kosmos-core/src/tree/panes.rs rename to core/src/tree/panes.rs index 42ce8da8..19bc9eea 100644 --- a/crates/kosmos-core/src/tree/panes.rs +++ b/core/src/tree/panes.rs @@ -77,6 +77,17 @@ impl PaneNode { axis: SplitAxis, new_pane: Pane, ratio: f32, + ) -> bool { + self.split_pane_with_new_pane_first(pane_id, axis, new_pane, ratio, false) + } + + pub fn split_pane_with_new_pane_first( + &mut self, + pane_id: PaneId, + axis: SplitAxis, + new_pane: Pane, + ratio: f32, + new_pane_first: bool, ) -> bool { validate_split_ratio(ratio); @@ -88,18 +99,32 @@ impl PaneNode { active_tab: pane.active_tab, }; - *self = Self::split(axis, ratio, Self::Leaf(existing_pane), Self::Leaf(new_pane)); + *self = if new_pane_first { + Self::split(axis, ratio, Self::Leaf(new_pane), Self::Leaf(existing_pane)) + } else { + Self::split(axis, ratio, Self::Leaf(existing_pane), Self::Leaf(new_pane)) + }; true } Self::Leaf(_) => false, Self::Split(split) => { if split.first().contains_pane(pane_id) { - split.first_mut().split_pane(pane_id, axis, new_pane, ratio) + split.first_mut().split_pane_with_new_pane_first( + pane_id, + axis, + new_pane, + ratio, + new_pane_first, + ) } else { - split - .second_mut() - .split_pane(pane_id, axis, new_pane, ratio) + split.second_mut().split_pane_with_new_pane_first( + pane_id, + axis, + new_pane, + ratio, + new_pane_first, + ) } } } @@ -179,6 +204,12 @@ impl SplitPane { self.ratio } + pub fn set_ratio(&mut self, ratio: f32) { + validate_split_ratio(ratio); + + self.ratio = ratio; + } + pub fn first(&self) -> &PaneNode { &self.first } @@ -234,7 +265,7 @@ impl Pane { pub fn active_tab_id(&self) -> TabId { self.active_tab - .expect("a pane exposed by kosmos-core must always have an active tab") + .expect("a pane exposed by core must always have an active tab") } pub fn active_tab(&self) -> &Tab { @@ -243,18 +274,44 @@ impl Pane { self.tabs .iter() .find(|tab| tab.id() == active_tab_id) - .expect("a pane exposed by kosmos-core must contain its active tab") + .expect("a pane exposed by core must contain its active tab") } pub fn add_tab(&mut self, tab: Tab) { + self.insert_tab(self.tabs.len(), tab); + } + + pub fn insert_tab(&mut self, index: usize, tab: Tab) { let tab_id = tab.id(); - self.tabs.push(tab); + let index = index.min(self.tabs.len()); + self.tabs.insert(index, tab); if self.active_tab.is_none() { self.active_tab = Some(tab_id); } } + pub fn reorder_tab(&mut self, tab_id: TabId, target_index: usize) -> bool { + let Some(current_index) = self.tabs.iter().position(|tab| tab.id() == tab_id) else { + return false; + }; + let target_index = target_index.min(self.tabs.len()); + let target_index = if target_index > current_index { + target_index - 1 + } else { + target_index + }; + + if current_index == target_index { + return false; + } + + let tab = self.tabs.remove(current_index); + self.tabs.insert(target_index, tab); + + true + } + pub fn activate_tab(&mut self, tab_id: TabId) -> bool { if self.contains_tab(tab_id) { self.active_tab = Some(tab_id); @@ -315,6 +372,21 @@ mod tests { assert_eq!(pane.active_tab_id(), TabId::new(2)); } + #[test] + fn reordering_tab_places_it_at_target_index() { + let mut pane = Pane::with_tab( + PaneId::new(1), + Tab::new(TabId::new(1), "First", TabKind::Blank), + ); + pane.add_tab(Tab::new(TabId::new(2), "Second", TabKind::Blank)); + pane.add_tab(Tab::new(TabId::new(3), "Third", TabKind::Blank)); + + assert!(pane.reorder_tab(TabId::new(1), 3)); + + let tab_ids = pane.tabs().iter().map(Tab::id).collect::>(); + assert_eq!(tab_ids, vec![TabId::new(2), TabId::new(3), TabId::new(1)]); + } + #[test] fn splitting_leaf_creates_recursive_node() { let first_pane = Pane::new( @@ -333,6 +405,34 @@ mod tests { assert!(root.contains_pane(PaneId::new(2))); } + #[test] + fn splitting_leaf_can_place_new_pane_first() { + let first_pane = Pane::new( + PaneId::new(1), + Tab::new(TabId::new(1), "First", TabKind::Blank), + ); + let second_pane = Pane::new( + PaneId::new(2), + Tab::new(TabId::new(2), "Second", TabKind::Blank), + ); + let mut root = PaneNode::leaf(first_pane); + + assert!(root.split_pane_with_new_pane_first( + PaneId::new(1), + SplitAxis::Horizontal, + second_pane, + 0.5, + true, + )); + + let PaneNode::Split(split) = root else { + panic!("splitting a leaf must create a split node"); + }; + + assert_eq!(split.first().first_pane_id(), PaneId::new(2)); + assert_eq!(split.second().first_pane_id(), PaneId::new(1)); + } + #[test] fn removing_pane_collapses_split_to_sibling() { let first_pane = Pane::new( diff --git a/crates/kosmos-core/src/tree/tabs.rs b/core/src/tree/tabs.rs similarity index 100% rename from crates/kosmos-core/src/tree/tabs.rs rename to core/src/tree/tabs.rs diff --git a/crates/kosmos-core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs similarity index 92% rename from crates/kosmos-core/src/tree/workspaces.rs rename to core/src/tree/workspaces.rs index 9f530d91..cb3b8834 100644 --- a/crates/kosmos-core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -128,14 +128,32 @@ impl Workspace { } pub fn add_tab_to_pane(&mut self, pane_id: PaneId, tab: Tab) -> bool { + self.insert_tab_in_pane(pane_id, usize::MAX, tab) + } + + pub fn insert_tab_in_pane(&mut self, pane_id: PaneId, index: usize, tab: Tab) -> bool { if let Some(pane) = self.root.find_pane_mut(pane_id) { - pane.add_tab(tab); + pane.insert_tab(index, tab); true } else { false } } + pub fn reorder_tab_in_pane( + &mut self, + pane_id: PaneId, + tab_id: TabId, + target_index: usize, + ) -> bool { + if let Some(pane) = self.root.find_pane_mut(pane_id) { + self.active_pane = pane_id; + pane.reorder_tab(tab_id, target_index) + } else { + false + } + } + pub fn activate_tab_in_active_pane(&mut self, tab_id: TabId) -> bool { self.active_pane_mut() .is_some_and(|pane| pane.activate_tab(tab_id)) @@ -168,6 +186,27 @@ impl Workspace { Some(removed_tab) } + pub fn split_pane_with_new_pane_first( + &mut self, + pane_id: PaneId, + axis: SplitAxis, + new_pane: Pane, + ratio: f32, + new_pane_first: bool, + ) -> bool { + let new_pane_id = new_pane.id(); + + if self + .root + .split_pane_with_new_pane_first(pane_id, axis, new_pane, ratio, new_pane_first) + { + self.active_pane = new_pane_id; + true + } else { + false + } + } + fn remove_empty_pane(&mut self, pane_id: PaneId, fallback_pane: Pane) { let fallback_pane_id = fallback_pane.id(); let old_root = std::mem::replace(&mut self.root, PaneNode::leaf(fallback_pane)); diff --git a/crates/kosmos-core/src/lib.rs b/crates/kosmos-core/src/lib.rs deleted file mode 100644 index 87821cc7..00000000 --- a/crates/kosmos-core/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod tree; diff --git a/crates/kosmos-ui/Cargo.toml b/crates/kosmos-ui/Cargo.toml deleted file mode 100644 index 4b4cef45..00000000 --- a/crates/kosmos-ui/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "kosmos-ui" -version = "0.1.0" -edition = "2024" - -[dependencies] -adw = { version = "0.9.1", package = "libadwaita", features = ["v1_5"] } -gtk = { version = "0.11.3", package = "gtk4", features = ["v4_10"] } -kosmos-core = { version = "0.1.0", path = "../kosmos-core" } diff --git a/crates/kosmos-ui/src/lib.rs b/crates/kosmos-ui/src/lib.rs deleted file mode 100644 index 1d2d2267..00000000 --- a/crates/kosmos-ui/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub fn main() { - println!("hello ui"); -} \ No newline at end of file diff --git a/crates/kosmos/Cargo.toml b/crates/kosmos/Cargo.toml deleted file mode 100644 index 941955a5..00000000 --- a/crates/kosmos/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "kosmos" -version = "0.1.0" -edition = "2024" - -[dependencies] -kosmos-ui = { version = "0.1.0", path = "../kosmos-ui" } diff --git a/crates/kosmos/src/main.rs b/crates/kosmos/src/main.rs deleted file mode 100644 index ad379d6e..00000000 --- a/crates/kosmos/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("hello world"); -} diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 00000000..3de49d2c --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_BIN="$ROOT_DIR/target/debug/kosmos-server" +UI_BUILD_DIR="$ROOT_DIR/ui/build" +UI_BIN="$UI_BUILD_DIR/kosmos-ui" +RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}" +export KOSMOS_SOCKET="${KOSMOS_SOCKET:-$RUNTIME_DIR/kosmos/server.sock}" + +server_pid="" + +cleanup() { + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" + wait "$server_pid" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +cargo build --package kosmos-server +cmake -S "$ROOT_DIR/ui" -B "$UI_BUILD_DIR" +cmake --build "$UI_BUILD_DIR" + +"$SERVER_BIN" & +server_pid="$!" + +for _ in {1..50}; do + if [[ -S "$KOSMOS_SOCKET" ]]; then + break + fi + + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "kosmos server failed to start" >&2 + exit 1 + fi + + sleep 0.1 +done + +if [[ ! -S "$KOSMOS_SOCKET" ]]; then + echo "kosmos server socket was not created at $KOSMOS_SOCKET" >&2 + exit 1 +fi + +"$UI_BIN" "$@" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 00000000..02bb01a7 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "kosmos-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +core = { version = "0.1.0", path = "../core" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" diff --git a/server/src/ipc/messages/envelope.rs b/server/src/ipc/messages/envelope.rs new file mode 100644 index 00000000..544b0825 --- /dev/null +++ b/server/src/ipc/messages/envelope.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum ClientMessage { + Request(RequestEnvelope), +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum ServerMessage { + Response(ResponseEnvelope), +} + +impl ServerMessage { + pub(crate) fn ok(id: u64, result: T) -> Self + where + T: Serialize, + { + let result = serde_json::to_value(result).expect("IPC responses must serialize"); + + Self::Response(ResponseEnvelope { + id, + ok: true, + result: Some(result), + error: None, + }) + } + + pub(crate) fn error(id: u64, code: impl Into, message: impl Into) -> Self { + Self::Response(ResponseEnvelope { + id, + ok: false, + result: None, + error: Some(ErrorEnvelope { + code: code.into(), + message: message.into(), + }), + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RequestEnvelope { + pub(crate) id: u64, + pub(crate) domain: Domain, + pub(crate) action: String, + #[serde(default)] + pub(crate) params: serde_json::Value, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Domain { + Workspace, + Pane, + Tab, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResponseEnvelope { + id: u64, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEnvelope { + code: String, + message: String, +} diff --git a/server/src/ipc/messages/mod.rs b/server/src/ipc/messages/mod.rs new file mode 100644 index 00000000..3e35c43e --- /dev/null +++ b/server/src/ipc/messages/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod envelope; +pub(crate) mod pane; +pub(crate) mod tab; +pub(crate) mod workspace; diff --git a/server/src/ipc/messages/pane.rs b/server/src/ipc/messages/pane.rs new file mode 100644 index 00000000..30980682 --- /dev/null +++ b/server/src/ipc/messages/pane.rs @@ -0,0 +1,111 @@ +use core::tree::{Pane, PaneId, PaneNode, SplitAxis}; +use serde::{Deserialize, Serialize}; + +use super::tab::TabSnapshot; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SplitPaneParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: Option, + pub(crate) axis: SplitAxisPayload, + pub(crate) new_pane_first: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActivatePaneParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub(crate) struct WorkspaceIdParam(u64); + +impl From for core::tree::WorkspaceId { + fn from(value: WorkspaceIdParam) -> Self { + Self::new(value.0) + } +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub(crate) struct PaneIdParam(u64); + +impl From for PaneId { + fn from(value: PaneIdParam) -> Self { + Self::new(value.0) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SplitAxisPayload { + Horizontal, + Vertical, +} + +impl From for SplitAxis { + fn from(value: SplitAxisPayload) -> Self { + match value { + SplitAxisPayload::Horizontal => Self::Horizontal, + SplitAxisPayload::Vertical => Self::Vertical, + } + } +} + +impl From for SplitAxisPayload { + fn from(value: SplitAxis) -> Self { + match value { + SplitAxis::Horizontal => Self::Horizontal, + SplitAxis::Vertical => Self::Vertical, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum PaneNodeSnapshot { + Leaf { + pane: PaneSnapshot, + }, + Split { + axis: SplitAxisPayload, + ratio: f32, + first: Box, + second: Box, + }, +} + +impl PaneNodeSnapshot { + pub(crate) fn from_node(node: &PaneNode) -> Self { + match node { + PaneNode::Leaf(pane) => Self::Leaf { + pane: PaneSnapshot::from_pane(pane), + }, + PaneNode::Split(split) => Self::Split { + axis: split.axis().into(), + ratio: split.ratio(), + first: Box::new(Self::from_node(split.first())), + second: Box::new(Self::from_node(split.second())), + }, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PaneSnapshot { + id: u64, + active_tab_id: u64, + tabs: Vec, +} + +impl PaneSnapshot { + fn from_pane(pane: &Pane) -> Self { + Self { + id: pane.id().value(), + active_tab_id: pane.active_tab_id().value(), + tabs: pane.tabs().iter().map(TabSnapshot::from_tab).collect(), + } + } +} diff --git a/server/src/ipc/messages/tab.rs b/server/src/ipc/messages/tab.rs new file mode 100644 index 00000000..f342f50c --- /dev/null +++ b/server/src/ipc/messages/tab.rs @@ -0,0 +1,106 @@ +use core::tree::{Tab, TabId, TabKind}; +use serde::{Deserialize, Serialize}; + +use super::pane::{PaneIdParam, WorkspaceIdParam}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: Option, + pub(crate) title: Option, + pub(crate) kind: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActivateTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) tab_id: TabIdParam, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloseTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) tab_id: TabIdParam, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReorderTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) tab_id: TabIdParam, + pub(crate) target_index: usize, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub(crate) struct TabIdParam(u64); + +impl From for TabId { + fn from(value: TabIdParam) -> Self { + Self::new(value.0) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TabKindPayload { + #[default] + Blank, + FileTree, + Editor, + Git, + Search, + Terminal, + Settings, +} + +impl From for TabKind { + fn from(value: TabKindPayload) -> Self { + match value { + TabKindPayload::Blank => Self::Blank, + TabKindPayload::FileTree => Self::FileTree, + TabKindPayload::Editor => Self::Editor, + TabKindPayload::Git => Self::Git, + TabKindPayload::Search => Self::Search, + TabKindPayload::Terminal => Self::Terminal, + TabKindPayload::Settings => Self::Settings, + } + } +} + +impl From<&TabKind> for TabKindPayload { + fn from(value: &TabKind) -> Self { + match value { + TabKind::Blank => Self::Blank, + TabKind::FileTree => Self::FileTree, + TabKind::Editor => Self::Editor, + TabKind::Git => Self::Git, + TabKind::Search => Self::Search, + TabKind::Terminal => Self::Terminal, + TabKind::Settings => Self::Settings, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TabSnapshot { + id: u64, + title: String, + kind: TabKindPayload, +} + +impl TabSnapshot { + pub(crate) fn from_tab(tab: &Tab) -> Self { + Self { + id: tab.id().value(), + title: tab.title().to_owned(), + kind: tab.kind().into(), + } + } +} diff --git a/server/src/ipc/messages/workspace.rs b/server/src/ipc/messages/workspace.rs new file mode 100644 index 00000000..7ab4988e --- /dev/null +++ b/server/src/ipc/messages/workspace.rs @@ -0,0 +1,64 @@ +use core::tree::{Workspace, WorkspaceList}; +use serde::{Deserialize, Serialize}; + +use super::pane::{PaneNodeSnapshot, WorkspaceIdParam}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenWorkspaceParams { + pub(crate) path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActivateWorkspaceParams { + pub(crate) workspace_id: WorkspaceIdParam, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloseWorkspaceParams { + pub(crate) workspace_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceListSnapshot { + active_workspace_id: Option, + workspaces: Vec, +} + +impl WorkspaceListSnapshot { + pub(crate) fn from_list(workspaces: &WorkspaceList) -> Self { + Self { + active_workspace_id: workspaces.active_workspace_id().map(|id| id.value()), + workspaces: workspaces + .workspaces() + .iter() + .map(WorkspaceSnapshot::from_workspace) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceSnapshot { + id: u64, + name: String, + directory: String, + active_pane_id: u64, + root: PaneNodeSnapshot, +} + +impl WorkspaceSnapshot { + fn from_workspace(workspace: &Workspace) -> Self { + Self { + id: workspace.id().value(), + name: workspace.name().to_owned(), + directory: workspace.directory().to_string_lossy().into_owned(), + active_pane_id: workspace.active_pane_id().value(), + root: PaneNodeSnapshot::from_node(workspace.root()), + } + } +} diff --git a/server/src/ipc/mod.rs b/server/src/ipc/mod.rs new file mode 100644 index 00000000..7ea525d6 --- /dev/null +++ b/server/src/ipc/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod messages; +pub(crate) mod router; +mod transport; + +pub(crate) use transport::server::run; diff --git a/server/src/ipc/router/mod.rs b/server/src/ipc/router/mod.rs new file mode 100644 index 00000000..8a1a1988 --- /dev/null +++ b/server/src/ipc/router/mod.rs @@ -0,0 +1,53 @@ +mod pane; +mod tab; +mod workspace; + +use serde::de::DeserializeOwned; + +use super::messages::envelope::{Domain, RequestEnvelope, ServerMessage}; +use super::messages::workspace::WorkspaceListSnapshot; + +pub(crate) fn route(state: &mut core::State, request: RequestEnvelope) -> ServerMessage { + match request.domain { + Domain::Workspace => workspace::route(state, &request), + Domain::Pane => pane::route(state, &request), + Domain::Tab => tab::route(state, &request), + } +} + +pub(super) fn parse_params(request: &RequestEnvelope) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_value(request.params.clone()) + .map_err(|error| ServerMessage::error(request.id, "ipc.invalid_params", error.to_string())) +} + +pub(super) fn command_response( + id: u64, + succeeded: bool, + state: &core::State, + error_code: &'static str, + error_message: impl Into, +) -> ServerMessage { + if succeeded { + workspace_list_response(id, state) + } else { + ServerMessage::error(id, error_code, error_message) + } +} + +pub(super) fn workspace_list_response(id: u64, state: &core::State) -> ServerMessage { + ServerMessage::ok(id, WorkspaceListSnapshot::from_list(state.workspaces())) +} + +pub(super) fn unsupported_action(request: &RequestEnvelope) -> ServerMessage { + ServerMessage::error( + request.id, + "ipc.unsupported_action", + format!( + "unsupported {:?}.{} IPC action", + request.domain, request.action + ), + ) +} diff --git a/server/src/ipc/router/pane.rs b/server/src/ipc/router/pane.rs new file mode 100644 index 00000000..61999d14 --- /dev/null +++ b/server/src/ipc/router/pane.rs @@ -0,0 +1,42 @@ +use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; +use super::super::messages::pane::{ActivatePaneParams, SplitPaneParams}; +use super::{command_response, parse_params, unsupported_action}; + +pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match request.action.as_str() { + "split" => split_pane(state, request), + "activate" => activate_pane(state, request), + _ => unsupported_action(request), + } +} + +fn split_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.split_pane( + params.workspace_id.map(Into::into), + params.pane_id.map(Into::into), + params.axis.into(), + params.new_pane_first.unwrap_or(false), + ), + state, + "pane.split_failed", + "pane could not be split", + ), + Err(response) => response, + } +} + +fn activate_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.activate_pane(params.workspace_id.map(Into::into), params.pane_id.into()), + state, + "pane.not_found", + "pane does not exist", + ), + Err(response) => response, + } +} diff --git a/server/src/ipc/router/tab.rs b/server/src/ipc/router/tab.rs new file mode 100644 index 00000000..ba46f1a0 --- /dev/null +++ b/server/src/ipc/router/tab.rs @@ -0,0 +1,87 @@ +use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; +use super::super::messages::tab::{ + ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, +}; +use super::{command_response, parse_params, unsupported_action}; + +pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match request.action.as_str() { + "open" => open_tab(state, request), + "activate" => activate_tab(state, request), + "close" => close_tab(state, request), + "reorder" => reorder_tab(state, request), + _ => unsupported_action(request), + } +} + +fn open_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.open_tab( + params.workspace_id.map(Into::into), + params.pane_id.map(Into::into), + params.title.unwrap_or_else(|| "Blank".to_owned()), + params.kind.unwrap_or_default().into(), + ), + state, + "tab.open_failed", + "tab could not be opened", + ), + Err(response) => response, + } +} + +fn activate_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.activate_tab( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.tab_id.into(), + ), + state, + "tab.not_found", + "tab does not exist", + ), + Err(response) => response, + } +} + +fn close_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state + .close_tab( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.tab_id.into(), + ) + .is_some(), + state, + "tab.not_found", + "tab does not exist", + ), + Err(response) => response, + } +} + +fn reorder_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.reorder_tab( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.tab_id.into(), + params.target_index, + ), + state, + "tab.reorder_failed", + "tab could not be reordered", + ), + Err(response) => response, + } +} diff --git a/server/src/ipc/router/workspace.rs b/server/src/ipc/router/workspace.rs new file mode 100644 index 00000000..5ad44485 --- /dev/null +++ b/server/src/ipc/router/workspace.rs @@ -0,0 +1,55 @@ +use std::path::PathBuf; + +use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; +use super::super::messages::workspace::{ + ActivateWorkspaceParams, CloseWorkspaceParams, OpenWorkspaceParams, +}; +use super::{command_response, parse_params, unsupported_action, workspace_list_response}; + +pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match request.action.as_str() { + "list" => workspace_list_response(request.id, state), + "open" => open_workspace(state, request), + "activate" => activate_workspace(state, request), + "close" => close_workspace(state, request), + _ => unsupported_action(request), + } +} + +fn open_workspace(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + state.open_workspace(PathBuf::from(params.path)); + workspace_list_response(request.id, state) + } + Err(response) => response, + } +} + +fn activate_workspace(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.activate_workspace(params.workspace_id.into()), + state, + "workspace.not_found", + "workspace does not exist", + ), + Err(response) => response, + } +} + +fn close_workspace(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state + .close_workspace(params.workspace_id.map(Into::into)) + .is_some(), + state, + "workspace.close_failed", + "workspace could not be closed", + ), + Err(response) => response, + } +} diff --git a/server/src/ipc/transport/codec.rs b/server/src/ipc/transport/codec.rs new file mode 100644 index 00000000..22100b8a --- /dev/null +++ b/server/src/ipc/transport/codec.rs @@ -0,0 +1,24 @@ +use std::io::{self, BufRead, Write}; + +use crate::ipc::messages::envelope::ServerMessage; + +pub(crate) fn read_frame(reader: &mut impl BufRead) -> io::Result> { + let mut frame = String::new(); + let bytes_read = reader.read_line(&mut frame)?; + + if bytes_read == 0 { + return Ok(None); + } + + while frame.ends_with(['\n', '\r']) { + frame.pop(); + } + + Ok(Some(frame)) +} + +pub(crate) fn write_message(writer: &mut impl Write, message: &ServerMessage) -> io::Result<()> { + serde_json::to_writer(&mut *writer, message).map_err(io::Error::other)?; + writer.write_all(b"\n")?; + writer.flush() +} diff --git a/server/src/ipc/transport/connection.rs b/server/src/ipc/transport/connection.rs new file mode 100644 index 00000000..fc9068cf --- /dev/null +++ b/server/src/ipc/transport/connection.rs @@ -0,0 +1,35 @@ +use std::io::{self, BufReader}; +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Mutex}; + +use crate::ipc::messages::envelope::{ClientMessage, ServerMessage}; +use crate::ipc::router; + +use super::codec; + +pub(crate) fn handle(stream: UnixStream, state: Arc>) -> io::Result<()> { + let reader_stream = stream.try_clone()?; + let mut reader = BufReader::new(reader_stream); + let mut writer = stream; + + while let Some(frame) = codec::read_frame(&mut reader)? { + if frame.trim().is_empty() { + continue; + } + + let response = match serde_json::from_str::(&frame) { + Ok(ClientMessage::Request(request)) => { + let mut state = state + .lock() + .map_err(|_| io::Error::other("IPC state mutex was poisoned"))?; + + router::route(&mut state, request) + } + Err(error) => ServerMessage::error(0, "ipc.invalid_message", error.to_string()), + }; + + codec::write_message(&mut writer, &response)?; + } + + Ok(()) +} diff --git a/server/src/ipc/transport/mod.rs b/server/src/ipc/transport/mod.rs new file mode 100644 index 00000000..15f7a98f --- /dev/null +++ b/server/src/ipc/transport/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod codec; +pub(crate) mod connection; +pub(crate) mod server; diff --git a/server/src/ipc/transport/server.rs b/server/src/ipc/transport/server.rs new file mode 100644 index 00000000..d427bb16 --- /dev/null +++ b/server/src/ipc/transport/server.rs @@ -0,0 +1,40 @@ +use std::fs; +use std::io; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +use super::connection; + +pub(crate) fn run(socket_path: PathBuf, state: core::State) -> io::Result<()> { + if let Some(parent) = socket_path.parent() { + fs::create_dir_all(parent)?; + } + + if socket_path.exists() { + fs::remove_file(&socket_path)?; + } + + let listener = UnixListener::bind(&socket_path)?; + let state = Arc::new(Mutex::new(state)); + + println!("kosmos server listening on {}", socket_path.display()); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let state = Arc::clone(&state); + + thread::spawn(move || { + if let Err(error) = connection::handle(stream, state) { + eprintln!("IPC connection failed: {error}"); + } + }); + } + Err(error) => eprintln!("IPC accept failed: {error}"), + } + } + + Ok(()) +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 00000000..199ce131 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,19 @@ +mod ipc; + +use std::path::PathBuf; + +fn main() -> std::io::Result<()> { + ipc::run(socket_path(), core::State::new()) +} + +fn socket_path() -> PathBuf { + if let Some(socket_path) = std::env::var_os("KOSMOS_SOCKET") { + return socket_path.into(); + } + + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("kosmos") + .join("server.sock") +} diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt new file mode 100644 index 00000000..6cd7c2e9 --- /dev/null +++ b/ui/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.20) + +project(kosmos_ui VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Network Widgets) + +add_executable(kosmos-ui + src/ipc/IpcClient.cpp + src/ipc/IpcClient.h + src/main.cpp +) + +target_link_libraries(kosmos-ui PRIVATE Qt6::Network Qt6::Widgets) diff --git a/ui/src/ipc/IpcClient.cpp b/ui/src/ipc/IpcClient.cpp new file mode 100644 index 00000000..c0f08bb0 --- /dev/null +++ b/ui/src/ipc/IpcClient.cpp @@ -0,0 +1,66 @@ +#include "IpcClient.h" + +#include + +IpcClient::IpcClient(QObject *parent) : QObject(parent) { + connect(&socket, &QLocalSocket::connected, this, &IpcClient::connected); + connect(&socket, &QLocalSocket::disconnected, this, &IpcClient::disconnected); + connect(&socket, &QLocalSocket::readyRead, this, &IpcClient::readMessages); + connect(&socket, &QLocalSocket::errorOccurred, this, [this](QLocalSocket::LocalSocketError) { + emit errorOccurred(socket.errorString()); + }); +} + +void IpcClient::connectToServer(const QString &socketPath) { + if (socket.state() != QLocalSocket::UnconnectedState) { + socket.abort(); + } + + socket.connectToServer(socketPath); +} + +quint64 IpcClient::sendRequest(const QString &domain, const QString &action, const QJsonObject ¶ms) { + const quint64 requestId = nextRequestId++; + + if (socket.state() != QLocalSocket::ConnectedState) { + emit errorOccurred(QStringLiteral("IPC socket is not connected")); + return requestId; + } + + const QJsonObject message{ + {QStringLiteral("type"), QStringLiteral("request")}, + {QStringLiteral("id"), static_cast(requestId)}, + {QStringLiteral("domain"), domain}, + {QStringLiteral("action"), action}, + {QStringLiteral("params"), params}, + }; + + QByteArray payload = QJsonDocument(message).toJson(QJsonDocument::Compact); + payload.append('\n'); + socket.write(payload); + + return requestId; +} + +void IpcClient::readMessages() { + buffer.append(socket.readAll()); + + qsizetype newlineIndex = buffer.indexOf('\n'); + while (newlineIndex >= 0) { + const QByteArray frame = buffer.left(newlineIndex).trimmed(); + buffer.remove(0, newlineIndex + 1); + + if (!frame.isEmpty()) { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(frame, &parseError); + + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + emit errorOccurred(QStringLiteral("Invalid IPC response: %1").arg(parseError.errorString())); + } else { + emit messageReceived(document.object()); + } + } + + newlineIndex = buffer.indexOf('\n'); + } +} diff --git a/ui/src/ipc/IpcClient.h b/ui/src/ipc/IpcClient.h new file mode 100644 index 00000000..df844d20 --- /dev/null +++ b/ui/src/ipc/IpcClient.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +class IpcClient final : public QObject { + Q_OBJECT + +public: + explicit IpcClient(QObject *parent = nullptr); + + void connectToServer(const QString &socketPath); + quint64 sendRequest(const QString &domain, const QString &action, const QJsonObject ¶ms = {}); + +signals: + void connected(); + void disconnected(); + void messageReceived(const QJsonObject &message); + void errorOccurred(const QString &message); + +private: + void readMessages(); + + QLocalSocket socket; + QByteArray buffer; + quint64 nextRequestId = 1; +}; diff --git a/ui/src/main.cpp b/ui/src/main.cpp new file mode 100644 index 00000000..9fc4533e --- /dev/null +++ b/ui/src/main.cpp @@ -0,0 +1,80 @@ +#include "ipc/IpcClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString socketPath() { + const QString configuredPath = qEnvironmentVariable("KOSMOS_SOCKET"); + if (!configuredPath.isEmpty()) { + return configuredPath; + } + + QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (runtimeDir.isEmpty()) { + runtimeDir = QDir::tempPath(); + } + + return QDir(runtimeDir).filePath(QStringLiteral("kosmos/server.sock")); +} + +} // namespace + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + + QMainWindow window; + window.setWindowTitle(QStringLiteral("Kosmos")); + window.resize(1200, 800); + + auto *central = new QWidget(&window); + auto *layout = new QVBoxLayout(central); + auto *status = new QLabel(QStringLiteral("Connecting to server..."), central); + auto *refresh = new QPushButton(QStringLiteral("Refresh workspaces"), central); + auto *messages = new QTextEdit(central); + + status->setTextInteractionFlags(Qt::TextSelectableByMouse); + messages->setReadOnly(true); + + layout->addWidget(status); + layout->addWidget(refresh); + layout->addWidget(messages, 1); + window.setCentralWidget(central); + + IpcClient ipc; + const QString serverSocketPath = socketPath(); + + QObject::connect(&ipc, &IpcClient::connected, [&]() { + status->setText(QStringLiteral("Connected to %1").arg(serverSocketPath)); + ipc.sendRequest(QStringLiteral("workspace"), QStringLiteral("list")); + }); + QObject::connect(&ipc, &IpcClient::disconnected, [&]() { + status->setText(QStringLiteral("Disconnected from server")); + }); + QObject::connect(&ipc, &IpcClient::errorOccurred, [&](const QString &message) { + status->setText(QStringLiteral("IPC error: %1").arg(message)); + messages->append(status->text()); + }); + QObject::connect(&ipc, &IpcClient::messageReceived, [&](const QJsonObject &message) { + const QString json = QString::fromUtf8(QJsonDocument(message).toJson(QJsonDocument::Indented)); + messages->append(json); + }); + QObject::connect(refresh, &QPushButton::clicked, [&]() { + ipc.sendRequest(QStringLiteral("workspace"), QStringLiteral("list")); + }); + + ipc.connectToServer(serverSocketPath); + + window.show(); + + return QApplication::exec(); +} From f34953d9027e3389f13f7b1580cfd567c4038603 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 14 Jun 2026 17:27:19 -0300 Subject: [PATCH 06/98] chore: restructure with gtk on c --- .gitignore | 1 - README.md | 6 +- desktop/README.md | 43 +++ desktop/meson.build | 30 ++ desktop/src/app/kosmos_application.c | 53 +++ desktop/src/app/kosmos_application.h | 13 + desktop/src/ipc/kosmos_ipc_client.c | 464 ++++++++++++++++++++++++++ desktop/src/ipc/kosmos_ipc_client.h | 67 ++++ desktop/src/ipc/kosmos_ipc_protocol.c | 46 +++ desktop/src/ipc/kosmos_ipc_protocol.h | 39 +++ desktop/src/main.c | 9 + desktop/src/ui/kosmos_main_window.c | 444 ++++++++++++++++++++++++ desktop/src/ui/kosmos_main_window.h | 16 + scripts/run.sh | 14 +- ui/CMakeLists.txt | 17 - ui/src/ipc/IpcClient.cpp | 66 ---- ui/src/ipc/IpcClient.h | 28 -- ui/src/main.cpp | 80 ----- 18 files changed, 1236 insertions(+), 200 deletions(-) create mode 100644 desktop/README.md create mode 100644 desktop/meson.build create mode 100644 desktop/src/app/kosmos_application.c create mode 100644 desktop/src/app/kosmos_application.h create mode 100644 desktop/src/ipc/kosmos_ipc_client.c create mode 100644 desktop/src/ipc/kosmos_ipc_client.h create mode 100644 desktop/src/ipc/kosmos_ipc_protocol.c create mode 100644 desktop/src/ipc/kosmos_ipc_protocol.h create mode 100644 desktop/src/main.c create mode 100644 desktop/src/ui/kosmos_main_window.c create mode 100644 desktop/src/ui/kosmos_main_window.h delete mode 100644 ui/CMakeLists.txt delete mode 100644 ui/src/ipc/IpcClient.cpp delete mode 100644 ui/src/ipc/IpcClient.h delete mode 100644 ui/src/main.cpp diff --git a/.gitignore b/.gitignore index 639d18c7..1a051534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ /target /core/target -/ui/build .claude .codex diff --git a/README.md b/README.md index 88ba49cf..9adfb555 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,16 @@ Kosmos only runs on Linux. Windows and macOS are not supported, and support for - `core/` contains the Rust logic layer. - `server/` contains the Rust backend process that imports `core`. -- `ui/` contains the Qt frontend. +- `desktop/` contains the C/GTK frontend. - `assets/` contains shared branding and icon assets. ## Development - Run the app with `./scripts/run.sh`. - Test the Rust workspace with `cargo test --workspace`. -- Build the Qt UI with `cmake -S ui -B ui/build` and `cmake --build ui/build`. +- Build the GTK desktop app with `meson setup desktop/build desktop` and `meson compile -C desktop/build`. -The server and UI communicate over a Unix socket. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. +The server and desktop frontend communicate over a Unix socket. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. ## License diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..bff7bc3f --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,43 @@ +# Kosmos Desktop + +GTK desktop frontend for Kosmos, written in C and built with Meson. + +## Requirements + +- Meson +- Ninja +- pkg-config +- GTK 4 development files +- JSON-GLib development files + +On Debian or Ubuntu, install the native dependencies with: + +```sh +sudo apt install meson ninja-build pkg-config libgtk-4-dev libjson-glib-dev +``` + +## Build + +From the repository root: + +```sh +meson setup desktop/build desktop +meson compile -C desktop/build +``` + +## Run + +```sh +./desktop/build/kosmos-desktop +``` + +The root `scripts/run.sh` script also builds the Rust server, starts it, and launches this GTK app. + +## Structure + +- `src/main.c` bootstraps the GTK application. +- `src/app/` owns GTK application lifecycle and initial server synchronization. +- `src/ui/` owns windows and widgets. +- `src/ipc/` owns Unix socket transport, newline-delimited JSON framing, and protocol helpers for `server/`. + +The IPC client resolves the socket with the same order as the server: `$KOSMOS_SOCKET`, then `$XDG_RUNTIME_DIR/kosmos/server.sock`, then `/tmp/kosmos/server.sock`. diff --git a/desktop/meson.build b/desktop/meson.build new file mode 100644 index 00000000..f74141a8 --- /dev/null +++ b/desktop/meson.build @@ -0,0 +1,30 @@ +project( + 'kosmos-desktop', + 'c', + version: '0.1.0', + default_options: [ + 'c_std=c17', + 'warning_level=2', + 'werror=false', + ], +) + +gtk_dep = dependency('gtk4') +gio_unix_dep = dependency('gio-unix-2.0') +json_glib_dep = dependency('json-glib-1.0') + +sources = files( + 'src/main.c', + 'src/app/kosmos_application.c', + 'src/ipc/kosmos_ipc_client.c', + 'src/ipc/kosmos_ipc_protocol.c', + 'src/ui/kosmos_main_window.c', +) + +executable( + 'kosmos-desktop', + sources, + include_directories: include_directories('src'), + dependencies: [gtk_dep, gio_unix_dep, json_glib_dep], + install: true, +) diff --git a/desktop/src/app/kosmos_application.c b/desktop/src/app/kosmos_application.c new file mode 100644 index 00000000..3a16178c --- /dev/null +++ b/desktop/src/app/kosmos_application.c @@ -0,0 +1,53 @@ +#include "app/kosmos_application.h" + +#include "ipc/kosmos_ipc_client.h" +#include "ui/kosmos_main_window.h" + +struct _KosmosApplication { + GtkApplication parent_instance; + KosmosIpcClient *ipc_client; +}; + +G_DEFINE_FINAL_TYPE(KosmosApplication, kosmos_application, GTK_TYPE_APPLICATION) + +static void kosmos_application_activate(GApplication *application) { + GtkWindow *active_window = gtk_application_get_active_window(GTK_APPLICATION(application)); + if (active_window != NULL) { + gtk_window_present(active_window); + return; + } + + KosmosApplication *self = KOSMOS_APPLICATION(application); + GtkWidget *window = kosmos_main_window_new(GTK_APPLICATION(application), self->ipc_client); + kosmos_main_window_refresh_workspace_state(KOSMOS_MAIN_WINDOW(window)); + gtk_window_present(GTK_WINDOW(window)); +} + +static void kosmos_application_finalize(GObject *object) { + KosmosApplication *self = KOSMOS_APPLICATION(object); + + g_clear_object(&self->ipc_client); + + G_OBJECT_CLASS(kosmos_application_parent_class)->finalize(object); +} + +static void kosmos_application_class_init(KosmosApplicationClass *klass) { + GApplicationClass *application_class = G_APPLICATION_CLASS(klass); + application_class->activate = kosmos_application_activate; + + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->finalize = kosmos_application_finalize; +} + +static void kosmos_application_init(KosmosApplication *self) { + self->ipc_client = kosmos_ipc_client_new_from_environment(); +} + +KosmosApplication *kosmos_application_new(void) { + return g_object_new( + KOSMOS_TYPE_APPLICATION, + "application-id", "dev.kosmos.Editor", + "flags", (GApplicationFlags)0, + NULL + ); +} diff --git a/desktop/src/app/kosmos_application.h b/desktop/src/app/kosmos_application.h new file mode 100644 index 00000000..ac713f1a --- /dev/null +++ b/desktop/src/app/kosmos_application.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +#define KOSMOS_TYPE_APPLICATION (kosmos_application_get_type()) + +G_DECLARE_FINAL_TYPE(KosmosApplication, kosmos_application, KOSMOS, APPLICATION, GtkApplication) + +KosmosApplication *kosmos_application_new(void); + +G_END_DECLS diff --git a/desktop/src/ipc/kosmos_ipc_client.c b/desktop/src/ipc/kosmos_ipc_client.c new file mode 100644 index 00000000..7fe8f0f5 --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_client.c @@ -0,0 +1,464 @@ +#include "ipc/kosmos_ipc_client.h" + +#include +#include + +struct _KosmosIpcClient { + GObject parent_instance; + char *socket_path; + GSocketConnection *connection; + GDataInputStream *input; + GOutputStream *output; + guint64 next_request_id; +}; + +G_DEFINE_FINAL_TYPE(KosmosIpcClient, kosmos_ipc_client, G_TYPE_OBJECT) +G_DEFINE_QUARK(kosmos-ipc-error-quark, kosmos_ipc_error) + +static gboolean set_not_connected_error(KosmosIpcClient *self, GError **error) { + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_NOT_CONNECTED, + "IPC client is not connected to %s", + self->socket_path + ); + return FALSE; +} + +static JsonNode *params_to_node(JsonObject *params) { + JsonNode *node = json_node_new(JSON_NODE_OBJECT); + json_node_set_object(node, params); + return node; +} + +static char *serialize_request(guint64 id, KosmosIpcDomain domain, const char *action, JsonObject *params) { + JsonObject *empty_params = NULL; + if (params == NULL) { + empty_params = json_object_new(); + params = empty_params; + } + + JsonBuilder *builder = json_builder_new(); + json_builder_begin_object(builder); + + json_builder_set_member_name(builder, "type"); + json_builder_add_string_value(builder, "request"); + + json_builder_set_member_name(builder, "id"); + json_builder_add_int_value(builder, (gint64)id); + + json_builder_set_member_name(builder, "domain"); + json_builder_add_string_value(builder, kosmos_ipc_domain_to_string(domain)); + + json_builder_set_member_name(builder, "action"); + json_builder_add_string_value(builder, action); + + json_builder_set_member_name(builder, "params"); + json_builder_add_value(builder, params_to_node(params)); + + json_builder_end_object(builder); + + JsonNode *root = json_builder_get_root(builder); + JsonGenerator *generator = json_generator_new(); + json_generator_set_root(generator, root); + char *payload = json_generator_to_data(generator, NULL); + + json_node_unref(root); + g_object_unref(generator); + g_object_unref(builder); + g_clear_pointer(&empty_params, json_object_unref); + + return payload; +} + +static gboolean write_frame(KosmosIpcClient *self, const char *payload, GCancellable *cancellable, GError **error) { + char *frame = g_strconcat(payload, "\n", NULL); + gboolean written = g_output_stream_write_all( + self->output, + frame, + strlen(frame), + NULL, + cancellable, + error + ); + + g_free(frame); + + if (!written || !g_output_stream_flush(self->output, cancellable, error)) { + kosmos_ipc_client_disconnect(self); + return FALSE; + } + + return TRUE; +} + +static char *read_frame(KosmosIpcClient *self, GCancellable *cancellable, GError **error) { + gsize length = 0; + char *line = g_data_input_stream_read_line_utf8(self->input, &length, cancellable, error); + + if (line == NULL && (error == NULL || *error == NULL)) { + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_IO, + "IPC server closed the connection" + ); + } + + if (line == NULL) { + kosmos_ipc_client_disconnect(self); + } + + return line; +} + +static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { + return FALSE; + } + + *value = json_node_get_string(node); + return TRUE; +} + +static gboolean get_uint_member(JsonObject *object, const char *name, guint64 *value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { + return FALSE; + } + + gint64 signed_value = json_node_get_int(node); + if (signed_value < 0) { + return FALSE; + } + + *value = (guint64)signed_value; + return TRUE; +} + +static gboolean get_bool_member(JsonObject *object, const char *name, gboolean *value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_BOOLEAN) { + return FALSE; + } + + *value = json_node_get_boolean(node); + return TRUE; +} + +static gboolean set_invalid_response_error(const char *reason, GError **error) { + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_INVALID_RESPONSE, + "Invalid IPC response: %s", + reason + ); + return FALSE; +} + +static gboolean set_server_response_error(JsonObject *response, GError **error) { + const char *code = "ipc.server_error"; + const char *message = "server returned an IPC error"; + + JsonNode *error_node = json_object_get_member(response, "error"); + if (error_node != NULL && JSON_NODE_HOLDS_OBJECT(error_node)) { + JsonObject *server_error = json_node_get_object(error_node); + get_string_member(server_error, "code", &code); + get_string_member(server_error, "message", &message); + } + + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_SERVER, + "%s: %s", + code, + message + ); + + return FALSE; +} + +static gboolean parse_response(const char *payload, guint64 request_id, JsonNode **result, GError **error) { + JsonParser *parser = json_parser_new(); + GError *parse_error = NULL; + + if (!json_parser_load_from_data(parser, payload, -1, &parse_error)) { + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_INVALID_RESPONSE, + "Invalid IPC response JSON: %s", + parse_error->message + ); + g_clear_error(&parse_error); + g_object_unref(parser); + return FALSE; + } + + JsonNode *root = json_parser_get_root(parser); + if (root == NULL || !JSON_NODE_HOLDS_OBJECT(root)) { + g_object_unref(parser); + return set_invalid_response_error("response root is not an object", error); + } + + JsonObject *response = json_node_get_object(root); + const char *type = NULL; + guint64 response_id = 0; + gboolean ok = FALSE; + + if (!get_string_member(response, "type", &type) || g_strcmp0(type, "response") != 0) { + g_object_unref(parser); + return set_invalid_response_error("missing response type", error); + } + + if (!get_uint_member(response, "id", &response_id) || response_id != request_id) { + g_object_unref(parser); + return set_invalid_response_error("response id does not match request id", error); + } + + if (!get_bool_member(response, "ok", &ok)) { + g_object_unref(parser); + return set_invalid_response_error("missing ok flag", error); + } + + if (!ok) { + gboolean server_error = set_server_response_error(response, error); + g_object_unref(parser); + return server_error; + } + + if (result != NULL) { + JsonNode *result_node = json_object_get_member(response, "result"); + *result = result_node == NULL ? NULL : json_node_copy(result_node); + } + + g_object_unref(parser); + return TRUE; +} + +static void kosmos_ipc_client_finalize(GObject *object) { + KosmosIpcClient *self = KOSMOS_IPC_CLIENT(object); + + kosmos_ipc_client_disconnect(self); + g_clear_pointer(&self->socket_path, g_free); + + G_OBJECT_CLASS(kosmos_ipc_client_parent_class)->finalize(object); +} + +static void kosmos_ipc_client_class_init(KosmosIpcClientClass *klass) { + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->finalize = kosmos_ipc_client_finalize; +} + +static void kosmos_ipc_client_init(KosmosIpcClient *self) { + self->next_request_id = 1; +} + +char *kosmos_ipc_default_socket_path(void) { + const char *socket_path = g_getenv("KOSMOS_SOCKET"); + if (socket_path != NULL && socket_path[0] != '\0') { + return g_strdup(socket_path); + } + + const char *runtime_dir = g_getenv("XDG_RUNTIME_DIR"); + if (runtime_dir == NULL || runtime_dir[0] == '\0') { + runtime_dir = g_get_tmp_dir(); + } + + return g_build_filename(runtime_dir, "kosmos", "server.sock", NULL); +} + +KosmosIpcClient *kosmos_ipc_client_new(const char *socket_path) { + KosmosIpcClient *self = g_object_new(KOSMOS_TYPE_IPC_CLIENT, NULL); + self->socket_path = socket_path == NULL || socket_path[0] == '\0' + ? kosmos_ipc_default_socket_path() + : g_strdup(socket_path); + + return self; +} + +KosmosIpcClient *kosmos_ipc_client_new_from_environment(void) { + return kosmos_ipc_client_new(NULL); +} + +const char *kosmos_ipc_client_get_socket_path(KosmosIpcClient *self) { + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), NULL); + + return self->socket_path; +} + +gboolean kosmos_ipc_client_is_connected(KosmosIpcClient *self) { + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); + + return self->connection != NULL && !g_io_stream_is_closed(G_IO_STREAM(self->connection)); +} + +gboolean kosmos_ipc_client_connect(KosmosIpcClient *self, GCancellable *cancellable, GError **error) { + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); + + if (kosmos_ipc_client_is_connected(self)) { + return TRUE; + } + + GSocket *socket = g_socket_new( + G_SOCKET_FAMILY_UNIX, + G_SOCKET_TYPE_STREAM, + G_SOCKET_PROTOCOL_DEFAULT, + error + ); + + if (socket == NULL) { + return FALSE; + } + + GSocketAddress *address = g_unix_socket_address_new(self->socket_path); + if (!g_socket_connect(socket, address, cancellable, error)) { + g_object_unref(address); + g_object_unref(socket); + return FALSE; + } + + self->connection = g_socket_connection_factory_create_connection(socket); + g_object_unref(address); + g_object_unref(socket); + + if (self->connection == NULL) { + g_set_error( + error, + KOSMOS_IPC_ERROR, + KOSMOS_IPC_ERROR_CONNECTION, + "failed to create IPC socket connection" + ); + return FALSE; + } + + self->input = g_data_input_stream_new(g_io_stream_get_input_stream(G_IO_STREAM(self->connection))); + g_data_input_stream_set_newline_type(self->input, G_DATA_STREAM_NEWLINE_TYPE_LF); + self->output = g_object_ref(g_io_stream_get_output_stream(G_IO_STREAM(self->connection))); + + return TRUE; +} + +void kosmos_ipc_client_disconnect(KosmosIpcClient *self) { + g_return_if_fail(KOSMOS_IS_IPC_CLIENT(self)); + + if (self->connection != NULL && !g_io_stream_is_closed(G_IO_STREAM(self->connection))) { + g_io_stream_close(G_IO_STREAM(self->connection), NULL, NULL); + } + + g_clear_object(&self->input); + g_clear_object(&self->output); + g_clear_object(&self->connection); +} + +gboolean kosmos_ipc_client_request( + KosmosIpcClient *self, + KosmosIpcDomain domain, + const char *action, + JsonObject *params, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); + g_return_val_if_fail(action != NULL, FALSE); + + if (result != NULL) { + *result = NULL; + } + + if (!kosmos_ipc_client_is_connected(self)) { + return set_not_connected_error(self, error); + } + + guint64 request_id = self->next_request_id++; + char *payload = serialize_request(request_id, domain, action, params); + + if (!write_frame(self, payload, cancellable, error)) { + g_free(payload); + return FALSE; + } + + g_free(payload); + + char *response = read_frame(self, cancellable, error); + if (response == NULL) { + return FALSE; + } + + gboolean parsed = parse_response(response, request_id, result, error); + g_free(response); + + return parsed; +} + +gboolean kosmos_ipc_client_list_workspaces( + KosmosIpcClient *self, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + return kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_LIST, + NULL, + result, + cancellable, + error + ); +} + +gboolean kosmos_ipc_client_open_workspace( + KosmosIpcClient *self, + const char *path, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_string_member(params, "path", path); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_OPEN, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_activate_workspace( + KosmosIpcClient *self, + guint64 workspace_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h new file mode 100644 index 00000000..76e95033 --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#include "ipc/kosmos_ipc_protocol.h" + +G_BEGIN_DECLS + +#define KOSMOS_TYPE_IPC_CLIENT (kosmos_ipc_client_get_type()) +#define KOSMOS_IPC_ERROR (kosmos_ipc_error_quark()) + +G_DECLARE_FINAL_TYPE(KosmosIpcClient, kosmos_ipc_client, KOSMOS, IPC_CLIENT, GObject) + +typedef enum { + KOSMOS_IPC_ERROR_CONNECTION, + KOSMOS_IPC_ERROR_NOT_CONNECTED, + KOSMOS_IPC_ERROR_IO, + KOSMOS_IPC_ERROR_INVALID_RESPONSE, + KOSMOS_IPC_ERROR_SERVER, +} KosmosIpcError; + +GQuark kosmos_ipc_error_quark(void); + +char *kosmos_ipc_default_socket_path(void); +KosmosIpcClient *kosmos_ipc_client_new(const char *socket_path); +KosmosIpcClient *kosmos_ipc_client_new_from_environment(void); + +const char *kosmos_ipc_client_get_socket_path(KosmosIpcClient *self); +gboolean kosmos_ipc_client_is_connected(KosmosIpcClient *self); +gboolean kosmos_ipc_client_connect(KosmosIpcClient *self, GCancellable *cancellable, GError **error); +void kosmos_ipc_client_disconnect(KosmosIpcClient *self); + +gboolean kosmos_ipc_client_request( + KosmosIpcClient *self, + KosmosIpcDomain domain, + const char *action, + JsonObject *params, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_list_workspaces( + KosmosIpcClient *self, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_open_workspace( + KosmosIpcClient *self, + const char *path, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_activate_workspace( + KosmosIpcClient *self, + guint64 workspace_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +G_END_DECLS diff --git a/desktop/src/ipc/kosmos_ipc_protocol.c b/desktop/src/ipc/kosmos_ipc_protocol.c new file mode 100644 index 00000000..c7831c2f --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_protocol.c @@ -0,0 +1,46 @@ +#include "ipc/kosmos_ipc_protocol.h" + +const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain) { + switch (domain) { + case KOSMOS_IPC_DOMAIN_WORKSPACE: + return "workspace"; + case KOSMOS_IPC_DOMAIN_PANE: + return "pane"; + case KOSMOS_IPC_DOMAIN_TAB: + return "tab"; + } + + g_return_val_if_reached("workspace"); +} + +const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis) { + switch (axis) { + case KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL: + return "horizontal"; + case KOSMOS_IPC_SPLIT_AXIS_VERTICAL: + return "vertical"; + } + + g_return_val_if_reached("horizontal"); +} + +const char *kosmos_ipc_tab_kind_to_string(KosmosIpcTabKind kind) { + switch (kind) { + case KOSMOS_IPC_TAB_KIND_BLANK: + return "blank"; + case KOSMOS_IPC_TAB_KIND_FILE_TREE: + return "fileTree"; + case KOSMOS_IPC_TAB_KIND_EDITOR: + return "editor"; + case KOSMOS_IPC_TAB_KIND_GIT: + return "git"; + case KOSMOS_IPC_TAB_KIND_SEARCH: + return "search"; + case KOSMOS_IPC_TAB_KIND_TERMINAL: + return "terminal"; + case KOSMOS_IPC_TAB_KIND_SETTINGS: + return "settings"; + } + + g_return_val_if_reached("blank"); +} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h new file mode 100644 index 00000000..2e7a3480 --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +typedef enum { + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_DOMAIN_TAB, +} KosmosIpcDomain; + +typedef enum { + KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, + KOSMOS_IPC_SPLIT_AXIS_VERTICAL, +} KosmosIpcSplitAxis; + +typedef enum { + KOSMOS_IPC_TAB_KIND_BLANK, + KOSMOS_IPC_TAB_KIND_FILE_TREE, + KOSMOS_IPC_TAB_KIND_EDITOR, + KOSMOS_IPC_TAB_KIND_GIT, + KOSMOS_IPC_TAB_KIND_SEARCH, + KOSMOS_IPC_TAB_KIND_TERMINAL, + KOSMOS_IPC_TAB_KIND_SETTINGS, +} KosmosIpcTabKind; + +#define KOSMOS_IPC_ACTION_LIST "list" +#define KOSMOS_IPC_ACTION_OPEN "open" +#define KOSMOS_IPC_ACTION_ACTIVATE "activate" +#define KOSMOS_IPC_ACTION_CLOSE "close" +#define KOSMOS_IPC_ACTION_SPLIT "split" +#define KOSMOS_IPC_ACTION_REORDER "reorder" + +const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain); +const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis); +const char *kosmos_ipc_tab_kind_to_string(KosmosIpcTabKind kind); + +G_END_DECLS diff --git a/desktop/src/main.c b/desktop/src/main.c new file mode 100644 index 00000000..1bace02f --- /dev/null +++ b/desktop/src/main.c @@ -0,0 +1,9 @@ +#include "app/kosmos_application.h" + +int main(int argc, char *argv[]) { + KosmosApplication *app = kosmos_application_new(); + int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + + return status; +} diff --git a/desktop/src/ui/kosmos_main_window.c b/desktop/src/ui/kosmos_main_window.c new file mode 100644 index 00000000..a862b9ba --- /dev/null +++ b/desktop/src/ui/kosmos_main_window.c @@ -0,0 +1,444 @@ +#include "ui/kosmos_main_window.h" + +struct _KosmosMainWindow { + GtkApplicationWindow parent_instance; + KosmosIpcClient *ipc_client; + GtkWidget *workspace_switcher; + GtkWidget *add_workspace_button; + GtkWidget *status_label; + GHashTable *workspace_buttons; +}; + +G_DEFINE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, GTK_TYPE_APPLICATION_WINDOW) + +static GtkWidget *create_label(const char *text, const char *css_class) { + GtkWidget *label = gtk_label_new(text); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + + if (css_class != NULL) { + gtk_widget_add_css_class(label, css_class); + } + + return label; +} + +static void clear_workspace_switcher(KosmosMainWindow *self) { + GtkWidget *child = gtk_widget_get_first_child(self->workspace_switcher); + + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(self->workspace_switcher), child); + child = next; + } + + g_hash_table_remove_all(self->workspace_buttons); + self->add_workspace_button = NULL; +} + +static gboolean get_uint_member(JsonObject *object, const char *name, guint64 *value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { + return FALSE; + } + + gint64 signed_value = json_node_get_int(node); + if (signed_value < 0) { + return FALSE; + } + + *value = (guint64)signed_value; + return TRUE; +} + +static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { + return FALSE; + } + + *value = json_node_get_string(node); + return TRUE; +} + +static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_workspace_id) { + JsonNode *node = json_object_get_member(result, "activeWorkspaceId"); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { + return FALSE; + } + + gint64 signed_value = json_node_get_int(node); + if (signed_value < 0) { + return FALSE; + } + + *active_workspace_id = (guint64)signed_value; + return TRUE; +} + +static void set_status(KosmosMainWindow *self, const char *status) { + gtk_label_set_text(GTK_LABEL(self->status_label), status); +} + +static void set_error_status(KosmosMainWindow *self, const char *prefix, GError *error) { + char *message = g_strdup_printf("%s: %s", prefix, error->message); + set_status(self, message); + g_free(message); +} + +static gboolean ensure_connected(KosmosMainWindow *self) { + GError *error = NULL; + + if (kosmos_ipc_client_connect(self->ipc_client, NULL, &error)) { + return TRUE; + } + + set_status(self, "Start kosmos-server with ./scripts/run.sh to load workspaces."); + clear_workspace_switcher(self); + g_clear_error(&error); + + return FALSE; +} + +static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state); + +static void activate_workspace(GtkWidget *button, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + guint64 *workspace_id = g_object_get_data(G_OBJECT(button), "workspace-id"); + + if (workspace_id == NULL || !ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + if (!kosmos_ipc_client_activate_workspace(self->ipc_client, *workspace_id, &state, NULL, &error)) { + set_error_status(self, "Failed to activate workspace", error); + g_clear_error(&error); + return; + } + + apply_workspace_state(self, state); + json_node_unref(state); +} + +static void open_workspace_path(KosmosMainWindow *self, const char *path) { + if (!ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + if (!kosmos_ipc_client_open_workspace(self->ipc_client, path, &state, NULL, &error)) { + set_error_status(self, "Failed to open workspace", error); + g_clear_error(&error); + return; + } + + apply_workspace_state(self, state); + json_node_unref(state); +} + +static void open_workspace_selected(GObject *source_object, GAsyncResult *result, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + GError *error = NULL; + GFile *folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source_object), result, &error); + + if (folder == NULL) { + if (!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { + set_error_status(self, "Failed to select workspace", error); + } + + g_clear_error(&error); + g_object_unref(self); + return; + } + + char *path = g_file_get_path(folder); + if (path == NULL) { + set_status(self, "Selected workspace is not a local directory."); + } else { + open_workspace_path(self, path); + } + + g_free(path); + g_object_unref(folder); + g_object_unref(self); +} + +static void open_workspace_picker(GtkButton *button, gpointer user_data) { + (void)button; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + GtkFileDialog *dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, "Open Workspace"); + gtk_file_dialog_select_folder(dialog, GTK_WINDOW(self), NULL, open_workspace_selected, g_object_ref(self)); + g_object_unref(dialog); +} + +static GtkWidget *ensure_add_workspace_button(KosmosMainWindow *self, gboolean sensitive) { + if (self->add_workspace_button == NULL) { + self->add_workspace_button = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_set_tooltip_text(self->add_workspace_button, "Open workspace"); + gtk_box_append(GTK_BOX(self->workspace_switcher), self->add_workspace_button); + g_signal_connect(self->add_workspace_button, "clicked", G_CALLBACK(open_workspace_picker), self); + } + + gtk_widget_set_sensitive(self->add_workspace_button, sensitive); + return self->add_workspace_button; +} + +static void split_workspace_name(const char *name, char **initial, char **suffix) { + if (name == NULL || name[0] == '\0') { + *initial = g_strdup("?"); + *suffix = g_strdup(""); + return; + } + + const char *suffix_start = g_utf8_next_char(name); + *initial = g_strndup(name, suffix_start - name); + *suffix = g_strdup(suffix_start); +} + +static void update_workspace_button_name(GtkWidget *button, const char *name) { + GtkWidget *initial_label = g_object_get_data(G_OBJECT(button), "workspace-initial-label"); + GtkWidget *suffix_label = g_object_get_data(G_OBJECT(button), "workspace-suffix-label"); + char *initial = NULL; + char *suffix = NULL; + + split_workspace_name(name, &initial, &suffix); + gtk_label_set_text(GTK_LABEL(initial_label), initial); + gtk_label_set_text(GTK_LABEL(suffix_label), suffix); + + g_free(initial); + g_free(suffix); +} + +static void set_workspace_button_active(GtkWidget *button, gboolean active) { + GtkWidget *revealer = g_object_get_data(G_OBJECT(button), "workspace-suffix-revealer"); + + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); + gtk_revealer_set_reveal_child(GTK_REVEALER(revealer), active); +} + +static GtkWidget *create_workspace_button(guint64 workspace_id, const char *name) { + GtkWidget *button = gtk_toggle_button_new(); + GtkWidget *label_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + GtkWidget *initial_label = gtk_label_new(NULL); + GtkWidget *suffix_label = gtk_label_new(NULL); + GtkWidget *revealer = gtk_revealer_new(); + + gtk_widget_set_halign(label_box, GTK_ALIGN_CENTER); + gtk_widget_set_valign(label_box, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(initial_label), 0.5f); + + gtk_revealer_set_transition_type(GTK_REVEALER(revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT); + gtk_revealer_set_transition_duration(GTK_REVEALER(revealer), 180); + gtk_revealer_set_child(GTK_REVEALER(revealer), suffix_label); + + gtk_box_append(GTK_BOX(label_box), initial_label); + gtk_box_append(GTK_BOX(label_box), revealer); + gtk_button_set_child(GTK_BUTTON(button), label_box); + + g_object_set_data(G_OBJECT(button), "workspace-initial-label", initial_label); + g_object_set_data(G_OBJECT(button), "workspace-suffix-label", suffix_label); + g_object_set_data(G_OBJECT(button), "workspace-suffix-revealer", revealer); + + guint64 *button_workspace_id = g_new(guint64, 1); + *button_workspace_id = workspace_id; + g_object_set_data_full(G_OBJECT(button), "workspace-id", button_workspace_id, g_free); + + update_workspace_button_name(button, name); + + return button; +} + +static GtkWidget *workspace_button_for(KosmosMainWindow *self, guint64 workspace_id, const char *name) { + GtkWidget *button = g_hash_table_lookup(self->workspace_buttons, &workspace_id); + + if (button != NULL) { + update_workspace_button_name(button, name); + return button; + } + + button = create_workspace_button(workspace_id, name); + guint64 *key = g_new(guint64, 1); + *key = workspace_id; + g_hash_table_insert(self->workspace_buttons, key, button); + gtk_box_append(GTK_BOX(self->workspace_switcher), button); + g_signal_connect(button, "clicked", G_CALLBACK(activate_workspace), self); + + return button; +} + +static void remove_stale_workspace_buttons(KosmosMainWindow *self, GHashTable *seen_workspace_ids) { + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + + g_hash_table_iter_init(&iter, self->workspace_buttons); + while (g_hash_table_iter_next(&iter, &key, &value)) { + if (!g_hash_table_contains(seen_workspace_ids, key)) { + gtk_box_remove(GTK_BOX(self->workspace_switcher), GTK_WIDGET(value)); + g_hash_table_iter_remove(&iter); + } + } +} + +static GtkWidget *update_workspace_button(KosmosMainWindow *self, JsonObject *workspace, guint64 active_workspace_id, gboolean has_active_workspace) { + guint64 workspace_id = 0; + const char *name = NULL; + + if (!get_uint_member(workspace, "id", &workspace_id) || !get_string_member(workspace, "name", &name)) { + return NULL; + } + + GtkWidget *button = workspace_button_for(self, workspace_id, name); + set_workspace_button_active(button, has_active_workspace && workspace_id == active_workspace_id); + + return button; +} + +static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { + if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { + set_status(self, "Workspace state is unavailable."); + return; + } + + JsonObject *result = json_node_get_object(state); + JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); + if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { + set_status(self, "Workspace state is missing workspaces."); + return; + } + + guint64 active_workspace_id = 0; + gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); + JsonArray *workspaces = json_node_get_array(workspaces_node); + guint workspace_count = json_array_get_length(workspaces); + GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + GtkWidget *previous_button = NULL; + + for (guint index = 0; index < workspace_count; index++) { + JsonNode *workspace_node = json_array_get_element(workspaces, index); + if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { + JsonObject *workspace = json_node_get_object(workspace_node); + guint64 workspace_id = 0; + + if (!get_uint_member(workspace, "id", &workspace_id)) { + continue; + } + + guint64 *seen_id = g_new(guint64, 1); + *seen_id = workspace_id; + g_hash_table_add(seen_workspace_ids, seen_id); + + GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); + if (button != NULL) { + gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); + previous_button = button; + } + } + } + + remove_stale_workspace_buttons(self, seen_workspace_ids); + gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), ensure_add_workspace_button(self, TRUE), previous_button); + g_hash_table_unref(seen_workspace_ids); + + if (workspace_count == 0) { + set_status(self, "No workspaces open. Use the plus button in the header to open one."); + return; + } + + set_status(self, has_active_workspace ? "Workspace state synced from kosmos-server." : "No active workspace selected."); +} + +static GtkWidget *create_workspace_switcher(KosmosMainWindow *self) { + self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_add_css_class(self->workspace_switcher, "linked"); + gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); + ensure_add_workspace_button(self, FALSE); + + return self->workspace_switcher; +} + +static GtkWidget *create_header(KosmosMainWindow *self) { + GtkWidget *header = gtk_header_bar_new(); + gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), create_workspace_switcher(self)); + + return header; +} + +static void kosmos_main_window_init(KosmosMainWindow *self) { + self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + + gtk_window_set_title(GTK_WINDOW(self), "Kosmos"); + gtk_window_set_default_size(GTK_WINDOW(self), 1000, 700); + + GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_window_set_child(GTK_WINDOW(self), root); + gtk_window_set_titlebar(GTK_WINDOW(self), create_header(self)); + + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16); + gtk_widget_set_margin_top(content, 64); + gtk_widget_set_margin_bottom(content, 64); + gtk_widget_set_margin_start(content, 64); + gtk_widget_set_margin_end(content, 64); + gtk_widget_set_vexpand(content, TRUE); + gtk_box_append(GTK_BOX(root), content); + + self->status_label = create_label("Loading workspace state...", "dim-label"); + gtk_widget_set_halign(self->status_label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(self->status_label, GTK_ALIGN_CENTER); + gtk_widget_set_vexpand(self->status_label, TRUE); + gtk_box_append(GTK_BOX(content), self->status_label); +} + +static void kosmos_main_window_finalize(GObject *object) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(object); + + g_clear_object(&self->ipc_client); + g_clear_pointer(&self->workspace_buttons, g_hash_table_unref); + + G_OBJECT_CLASS(kosmos_main_window_parent_class)->finalize(object); +} + +static void kosmos_main_window_class_init(KosmosMainWindowClass *klass) { + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->finalize = kosmos_main_window_finalize; +} + +GtkWidget *kosmos_main_window_new(GtkApplication *application, KosmosIpcClient *ipc_client) { + g_return_val_if_fail(GTK_IS_APPLICATION(application), NULL); + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(ipc_client), NULL); + + KosmosMainWindow *self = g_object_new( + KOSMOS_TYPE_MAIN_WINDOW, + "application", application, + NULL + ); + + self->ipc_client = g_object_ref(ipc_client); + + return GTK_WIDGET(self); +} + +void kosmos_main_window_refresh_workspace_state(KosmosMainWindow *self) { + g_return_if_fail(KOSMOS_IS_MAIN_WINDOW(self)); + + if (!ensure_connected(self)) { + ensure_add_workspace_button(self, FALSE); + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + if (!kosmos_ipc_client_list_workspaces(self->ipc_client, &state, NULL, &error)) { + set_error_status(self, "Failed to load workspaces", error); + g_clear_error(&error); + return; + } + + apply_workspace_state(self, state); + json_node_unref(state); +} diff --git a/desktop/src/ui/kosmos_main_window.h b/desktop/src/ui/kosmos_main_window.h new file mode 100644 index 00000000..d16bd600 --- /dev/null +++ b/desktop/src/ui/kosmos_main_window.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include "ipc/kosmos_ipc_client.h" + +G_BEGIN_DECLS + +#define KOSMOS_TYPE_MAIN_WINDOW (kosmos_main_window_get_type()) + +G_DECLARE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, KOSMOS, MAIN_WINDOW, GtkApplicationWindow) + +GtkWidget *kosmos_main_window_new(GtkApplication *application, KosmosIpcClient *ipc_client); +void kosmos_main_window_refresh_workspace_state(KosmosMainWindow *self); + +G_END_DECLS diff --git a/scripts/run.sh b/scripts/run.sh index 3de49d2c..d25d5678 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -3,8 +3,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SERVER_BIN="$ROOT_DIR/target/debug/kosmos-server" -UI_BUILD_DIR="$ROOT_DIR/ui/build" -UI_BIN="$UI_BUILD_DIR/kosmos-ui" +DESKTOP_BUILD_DIR="$ROOT_DIR/desktop/build" +DESKTOP_BIN="$DESKTOP_BUILD_DIR/kosmos-desktop" RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}" export KOSMOS_SOCKET="${KOSMOS_SOCKET:-$RUNTIME_DIR/kosmos/server.sock}" @@ -20,8 +20,12 @@ cleanup() { trap cleanup EXIT INT TERM cargo build --package kosmos-server -cmake -S "$ROOT_DIR/ui" -B "$UI_BUILD_DIR" -cmake --build "$UI_BUILD_DIR" +if [[ -f "$DESKTOP_BUILD_DIR/build.ninja" ]]; then + meson setup --reconfigure "$DESKTOP_BUILD_DIR" "$ROOT_DIR/desktop" +else + meson setup "$DESKTOP_BUILD_DIR" "$ROOT_DIR/desktop" +fi +meson compile -C "$DESKTOP_BUILD_DIR" "$SERVER_BIN" & server_pid="$!" @@ -44,4 +48,4 @@ if [[ ! -S "$KOSMOS_SOCKET" ]]; then exit 1 fi -"$UI_BIN" "$@" +"$DESKTOP_BIN" "$@" diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt deleted file mode 100644 index 6cd7c2e9..00000000 --- a/ui/CMakeLists.txt +++ /dev/null @@ -1,17 +0,0 @@ -cmake_minimum_required(VERSION 3.20) - -project(kosmos_ui VERSION 0.1.0 LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) - -find_package(Qt6 REQUIRED COMPONENTS Network Widgets) - -add_executable(kosmos-ui - src/ipc/IpcClient.cpp - src/ipc/IpcClient.h - src/main.cpp -) - -target_link_libraries(kosmos-ui PRIVATE Qt6::Network Qt6::Widgets) diff --git a/ui/src/ipc/IpcClient.cpp b/ui/src/ipc/IpcClient.cpp deleted file mode 100644 index c0f08bb0..00000000 --- a/ui/src/ipc/IpcClient.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "IpcClient.h" - -#include - -IpcClient::IpcClient(QObject *parent) : QObject(parent) { - connect(&socket, &QLocalSocket::connected, this, &IpcClient::connected); - connect(&socket, &QLocalSocket::disconnected, this, &IpcClient::disconnected); - connect(&socket, &QLocalSocket::readyRead, this, &IpcClient::readMessages); - connect(&socket, &QLocalSocket::errorOccurred, this, [this](QLocalSocket::LocalSocketError) { - emit errorOccurred(socket.errorString()); - }); -} - -void IpcClient::connectToServer(const QString &socketPath) { - if (socket.state() != QLocalSocket::UnconnectedState) { - socket.abort(); - } - - socket.connectToServer(socketPath); -} - -quint64 IpcClient::sendRequest(const QString &domain, const QString &action, const QJsonObject ¶ms) { - const quint64 requestId = nextRequestId++; - - if (socket.state() != QLocalSocket::ConnectedState) { - emit errorOccurred(QStringLiteral("IPC socket is not connected")); - return requestId; - } - - const QJsonObject message{ - {QStringLiteral("type"), QStringLiteral("request")}, - {QStringLiteral("id"), static_cast(requestId)}, - {QStringLiteral("domain"), domain}, - {QStringLiteral("action"), action}, - {QStringLiteral("params"), params}, - }; - - QByteArray payload = QJsonDocument(message).toJson(QJsonDocument::Compact); - payload.append('\n'); - socket.write(payload); - - return requestId; -} - -void IpcClient::readMessages() { - buffer.append(socket.readAll()); - - qsizetype newlineIndex = buffer.indexOf('\n'); - while (newlineIndex >= 0) { - const QByteArray frame = buffer.left(newlineIndex).trimmed(); - buffer.remove(0, newlineIndex + 1); - - if (!frame.isEmpty()) { - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(frame, &parseError); - - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - emit errorOccurred(QStringLiteral("Invalid IPC response: %1").arg(parseError.errorString())); - } else { - emit messageReceived(document.object()); - } - } - - newlineIndex = buffer.indexOf('\n'); - } -} diff --git a/ui/src/ipc/IpcClient.h b/ui/src/ipc/IpcClient.h deleted file mode 100644 index df844d20..00000000 --- a/ui/src/ipc/IpcClient.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include -#include - -class IpcClient final : public QObject { - Q_OBJECT - -public: - explicit IpcClient(QObject *parent = nullptr); - - void connectToServer(const QString &socketPath); - quint64 sendRequest(const QString &domain, const QString &action, const QJsonObject ¶ms = {}); - -signals: - void connected(); - void disconnected(); - void messageReceived(const QJsonObject &message); - void errorOccurred(const QString &message); - -private: - void readMessages(); - - QLocalSocket socket; - QByteArray buffer; - quint64 nextRequestId = 1; -}; diff --git a/ui/src/main.cpp b/ui/src/main.cpp deleted file mode 100644 index 9fc4533e..00000000 --- a/ui/src/main.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "ipc/IpcClient.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -QString socketPath() { - const QString configuredPath = qEnvironmentVariable("KOSMOS_SOCKET"); - if (!configuredPath.isEmpty()) { - return configuredPath; - } - - QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); - if (runtimeDir.isEmpty()) { - runtimeDir = QDir::tempPath(); - } - - return QDir(runtimeDir).filePath(QStringLiteral("kosmos/server.sock")); -} - -} // namespace - -int main(int argc, char *argv[]) { - QApplication app(argc, argv); - - QMainWindow window; - window.setWindowTitle(QStringLiteral("Kosmos")); - window.resize(1200, 800); - - auto *central = new QWidget(&window); - auto *layout = new QVBoxLayout(central); - auto *status = new QLabel(QStringLiteral("Connecting to server..."), central); - auto *refresh = new QPushButton(QStringLiteral("Refresh workspaces"), central); - auto *messages = new QTextEdit(central); - - status->setTextInteractionFlags(Qt::TextSelectableByMouse); - messages->setReadOnly(true); - - layout->addWidget(status); - layout->addWidget(refresh); - layout->addWidget(messages, 1); - window.setCentralWidget(central); - - IpcClient ipc; - const QString serverSocketPath = socketPath(); - - QObject::connect(&ipc, &IpcClient::connected, [&]() { - status->setText(QStringLiteral("Connected to %1").arg(serverSocketPath)); - ipc.sendRequest(QStringLiteral("workspace"), QStringLiteral("list")); - }); - QObject::connect(&ipc, &IpcClient::disconnected, [&]() { - status->setText(QStringLiteral("Disconnected from server")); - }); - QObject::connect(&ipc, &IpcClient::errorOccurred, [&](const QString &message) { - status->setText(QStringLiteral("IPC error: %1").arg(message)); - messages->append(status->text()); - }); - QObject::connect(&ipc, &IpcClient::messageReceived, [&](const QJsonObject &message) { - const QString json = QString::fromUtf8(QJsonDocument(message).toJson(QJsonDocument::Indented)); - messages->append(json); - }); - QObject::connect(refresh, &QPushButton::clicked, [&]() { - ipc.sendRequest(QStringLiteral("workspace"), QStringLiteral("list")); - }); - - ipc.connectToServer(serverSocketPath); - - window.show(); - - return QApplication::exec(); -} From caca646fc57a4ac219a5ca969741d7c3cd981e17 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 14 Jun 2026 23:48:04 -0300 Subject: [PATCH 07/98] feat: add workspaces, panes and tabs --- core/src/state.rs | 163 +++ core/src/tree/workspaces.rs | 89 ++ desktop/README.md | 3 +- desktop/meson.build | 3 +- desktop/src/ipc/kosmos_ipc_client.c | 238 +++ desktop/src/ipc/kosmos_ipc_client.h | 87 ++ desktop/src/ipc/kosmos_ipc_protocol.h | 1 + desktop/src/main.c | 4 + desktop/src/ui/kosmos_main_window.c | 1927 ++++++++++++++++++++++++- server/src/ipc/messages/pane.rs | 10 + server/src/ipc/messages/tab.rs | 13 +- server/src/ipc/router/pane.rs | 22 +- server/src/ipc/router/tab.rs | 26 +- 13 files changed, 2515 insertions(+), 71 deletions(-) diff --git a/core/src/state.rs b/core/src/state.rs index aa57429e..376e77d6 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -72,6 +72,24 @@ impl State { workspace.activate_pane(pane_id) } + pub fn move_pane( + &mut self, + workspace_id: Option, + pane_id: PaneId, + target_pane_id: PaneId, + axis: SplitAxis, + new_pane_first: bool, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.move_pane_to_split(pane_id, target_pane_id, axis, 0.5, new_pane_first) + } + pub fn open_tab( &mut self, workspace_id: Option, @@ -113,6 +131,59 @@ impl State { workspace.activate_tab(pane_id, tab_id) } + pub fn split_tab( + &mut self, + workspace_id: Option, + pane_id: PaneId, + target_pane_id: PaneId, + tab_id: TabId, + axis: SplitAxis, + new_pane_first: bool, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspaces.workspace(workspace_id) else { + return false; + }; + let Some(source_pane) = workspace.root().find_pane(pane_id) else { + return false; + }; + if !workspace.root().contains_pane(target_pane_id) { + return false; + } + if !source_pane.contains_tab(tab_id) { + return false; + } + + let fallback_tab = + (source_pane.tabs().len() == 1).then(|| self.next_tab("Blank", TabKind::Blank)); + let new_pane_id = self.next_pane_id(); + + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + let Some(source_pane) = workspace.root_mut().find_pane_mut(pane_id) else { + return false; + }; + let Some(tab) = source_pane.remove_tab(tab_id) else { + return false; + }; + + if let Some(fallback_tab) = fallback_tab { + source_pane.insert_tab(0, fallback_tab); + } + + let new_pane = Pane::new(new_pane_id, tab); + workspace.split_pane_with_new_pane_first( + target_pane_id, + axis, + new_pane, + 0.5, + new_pane_first, + ) + } + pub fn close_tab( &mut self, workspace_id: Option, @@ -225,4 +296,96 @@ mod tests { assert_eq!(pane.tabs().len(), 2); assert_eq!(pane.active_tab().title(), "Search"); } + + #[test] + fn splitting_tab_moves_it_to_a_new_pane() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + state.open_tab(None, None, "Search", TabKind::Search); + + assert!(state.split_tab( + None, + PaneId::new(1), + PaneId::new(1), + TabId::new(2), + SplitAxis::Horizontal, + false, + )); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + + assert_eq!(workspace.root().pane_count(), 2); + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + + let source_pane = workspace + .root() + .find_pane(PaneId::new(1)) + .expect("source pane should remain"); + let new_pane = workspace + .root() + .find_pane(PaneId::new(2)) + .expect("new pane should exist"); + + assert_eq!(source_pane.tabs().len(), 1); + assert_eq!(new_pane.active_tab().id(), TabId::new(2)); + } + + #[test] + fn splitting_only_tab_keeps_source_pane_valid() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + + assert!(state.split_tab( + None, + PaneId::new(1), + PaneId::new(1), + TabId::new(1), + SplitAxis::Vertical, + false, + )); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let source_pane = workspace + .root() + .find_pane(PaneId::new(1)) + .expect("source pane should remain"); + let new_pane = workspace + .root() + .find_pane(PaneId::new(2)) + .expect("new pane should exist"); + + assert_eq!(source_pane.tabs().len(), 1); + assert_eq!(new_pane.active_tab().id(), TabId::new(1)); + } + + #[test] + fn moving_pane_reuses_existing_pane() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + assert!(state.split_pane(None, None, SplitAxis::Horizontal, false)); + + assert!(state.move_pane( + None, + PaneId::new(1), + PaneId::new(2), + SplitAxis::Vertical, + false, + )); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + + assert_eq!(workspace.root().pane_count(), 2); + assert!(workspace.root().contains_pane(PaneId::new(1))); + assert!(workspace.root().contains_pane(PaneId::new(2))); + assert_eq!(workspace.active_pane_id(), PaneId::new(1)); + } } diff --git a/core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs index cb3b8834..00b5f1dc 100644 --- a/core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -207,6 +207,36 @@ impl Workspace { } } + pub fn move_pane_to_split( + &mut self, + pane_id: PaneId, + target_pane_id: PaneId, + axis: SplitAxis, + ratio: f32, + new_pane_first: bool, + ) -> bool { + if pane_id == target_pane_id { + return self.root.contains_pane(pane_id); + } + + if !self.root.contains_pane(pane_id) || !self.root.contains_pane(target_pane_id) { + return false; + } + + let Some((mut root, pane)) = remove_pane_from_root(&self.root, pane_id) else { + return false; + }; + let pane_id = pane.id(); + + if !root.split_pane_with_new_pane_first(target_pane_id, axis, pane, ratio, new_pane_first) { + return false; + } + + self.root = root; + self.active_pane = pane_id; + true + } + fn remove_empty_pane(&mut self, pane_id: PaneId, fallback_pane: Pane) { let fallback_pane_id = fallback_pane.id(); let old_root = std::mem::replace(&mut self.root, PaneNode::leaf(fallback_pane)); @@ -223,6 +253,12 @@ impl Workspace { } } +fn remove_pane_from_root(root: &PaneNode, pane_id: PaneId) -> Option<(PaneNode, Pane)> { + let (root, pane) = root.clone().remove_pane(pane_id); + + Some((root?, pane?)) +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct WorkspaceList { workspaces: Vec, @@ -397,6 +433,59 @@ mod tests { assert_eq!(workspace.root().pane_count(), 1); } + #[test] + fn moving_pane_splits_target_with_existing_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + workspace.split_active_pane(SplitAxis::Horizontal, pane(2, 2), 0.5); + workspace.split_pane_with_new_pane_first( + PaneId::new(1), + SplitAxis::Vertical, + pane(3, 3), + 0.5, + false, + ); + + assert!(workspace.move_pane_to_split( + PaneId::new(3), + PaneId::new(2), + SplitAxis::Horizontal, + 0.5, + true, + )); + + assert_eq!(workspace.root().pane_count(), 3); + assert!(workspace.root().contains_pane(PaneId::new(1))); + assert!(workspace.root().contains_pane(PaneId::new(2))); + assert!(workspace.root().contains_pane(PaneId::new(3))); + assert_eq!(workspace.active_pane_id(), PaneId::new(3)); + + let PaneNode::Split(root_split) = workspace.root() else { + panic!("workspace root should remain split"); + }; + let PaneNode::Split(moved_split) = root_split.second() else { + panic!("target side should be split with moved pane"); + }; + + assert_eq!(moved_split.first().first_pane_id(), PaneId::new(3)); + assert_eq!(moved_split.second().first_pane_id(), PaneId::new(2)); + } + + #[test] + fn moving_pane_to_itself_is_noop() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + + assert!(workspace.move_pane_to_split( + PaneId::new(1), + PaneId::new(1), + SplitAxis::Horizontal, + 0.5, + false, + )); + + assert_eq!(workspace.root().pane_count(), 1); + assert!(workspace.root().contains_pane(PaneId::new(1))); + } + #[test] fn closing_last_workspace_tab_respawns_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); diff --git a/desktop/README.md b/desktop/README.md index bff7bc3f..8df5e947 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -9,11 +9,12 @@ GTK desktop frontend for Kosmos, written in C and built with Meson. - pkg-config - GTK 4 development files - JSON-GLib development files +- libadwaita development files On Debian or Ubuntu, install the native dependencies with: ```sh -sudo apt install meson ninja-build pkg-config libgtk-4-dev libjson-glib-dev +sudo apt install meson ninja-build pkg-config libgtk-4-dev libjson-glib-dev libadwaita-1-dev ``` ## Build diff --git a/desktop/meson.build b/desktop/meson.build index f74141a8..ab7444dd 100644 --- a/desktop/meson.build +++ b/desktop/meson.build @@ -12,6 +12,7 @@ project( gtk_dep = dependency('gtk4') gio_unix_dep = dependency('gio-unix-2.0') json_glib_dep = dependency('json-glib-1.0') +libadwaita_dep = dependency('libadwaita-1') sources = files( 'src/main.c', @@ -25,6 +26,6 @@ executable( 'kosmos-desktop', sources, include_directories: include_directories('src'), - dependencies: [gtk_dep, gio_unix_dep, json_glib_dep], + dependencies: [gtk_dep, gio_unix_dep, json_glib_dep, libadwaita_dep], install: true, ) diff --git a/desktop/src/ipc/kosmos_ipc_client.c b/desktop/src/ipc/kosmos_ipc_client.c index 7fe8f0f5..6e9533bc 100644 --- a/desktop/src/ipc/kosmos_ipc_client.c +++ b/desktop/src/ipc/kosmos_ipc_client.c @@ -462,3 +462,241 @@ gboolean kosmos_ipc_client_activate_workspace( json_object_unref(params); return requested; } + +gboolean kosmos_ipc_client_activate_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_split_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_SPLIT, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_move_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_MOVE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_open_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + const char *title, + KosmosIpcTabKind kind, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_string_member(params, "title", title == NULL ? "Blank" : title); + json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_OPEN, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_activate_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_close_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_CLOSE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_reorder_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + guint target_index, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + json_object_set_int_member(params, "targetIndex", (gint64)target_index); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_REORDER, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_split_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + guint64 tab_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_SPLIT, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h index 76e95033..e26d91f9 100644 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -64,4 +64,91 @@ gboolean kosmos_ipc_client_activate_workspace( GError **error ); +gboolean kosmos_ipc_client_activate_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_split_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_move_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_open_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + const char *title, + KosmosIpcTabKind kind, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_activate_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_close_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_reorder_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + guint target_index, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_split_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + guint64 tab_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + G_END_DECLS diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h index 2e7a3480..11c5e133 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -31,6 +31,7 @@ typedef enum { #define KOSMOS_IPC_ACTION_CLOSE "close" #define KOSMOS_IPC_ACTION_SPLIT "split" #define KOSMOS_IPC_ACTION_REORDER "reorder" +#define KOSMOS_IPC_ACTION_MOVE "move" const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain); const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis); diff --git a/desktop/src/main.c b/desktop/src/main.c index 1bace02f..9b402606 100644 --- a/desktop/src/main.c +++ b/desktop/src/main.c @@ -1,6 +1,10 @@ +#include + #include "app/kosmos_application.h" int main(int argc, char *argv[]) { + adw_init(); + KosmosApplication *app = kosmos_application_new(); int status = g_application_run(G_APPLICATION(app), argc, argv); g_object_unref(app); diff --git a/desktop/src/ui/kosmos_main_window.c b/desktop/src/ui/kosmos_main_window.c index a862b9ba..4747b3a6 100644 --- a/desktop/src/ui/kosmos_main_window.c +++ b/desktop/src/ui/kosmos_main_window.c @@ -1,14 +1,79 @@ #include "ui/kosmos_main_window.h" +#include + struct _KosmosMainWindow { GtkApplicationWindow parent_instance; KosmosIpcClient *ipc_client; GtkWidget *workspace_switcher; GtkWidget *add_workspace_button; - GtkWidget *status_label; + GtkWidget *content_overlay; + GtkWidget *content_area; GHashTable *workspace_buttons; + GHashTable *paned_ratios; + GHashTable *pane_views; + char *layout_signature; + AdwTabView *detached_tab_sink; + GtkWindow *detached_tab_window; + gboolean splitting_detached_tab; + gboolean suppress_tab_detach; + gboolean applying_server_state; }; +typedef struct { + KosmosMainWindow *window; + guint64 workspace_id; + guint64 pane_id; + guint64 target_pane_id; + guint64 tab_id; + KosmosIpcSplitAxis axis; + gboolean new_pane_first; +} DetachedTabSplit; + +typedef struct { + guint64 workspace_id; + guint64 pane_id; + guint64 tab_id; +} PendingTabActivation; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; +} TabBarActivation; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; +} DeferredTabActivation; + +typedef struct { + guint64 target_pane_id; + KosmosIpcSplitAxis axis; + gboolean new_pane_first; +} SplitDropZone; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; + GtkWidget *source; + GtkWidget *excluded_widget; + GtkWidget *preview; + char *title; + guint64 workspace_id; + guint64 pane_id; + double start_x; + double start_y; + gboolean active; +} PaneDrag; + +typedef struct { + GtkOrientation orientation; + double ratio; +} PanedRatio; + +static const int KOSMOS_MIN_PANE_WIDTH = 220; +static const int KOSMOS_MIN_PANE_HEIGHT = 160; + G_DEFINE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, GTK_TYPE_APPLICATION_WINDOW) static GtkWidget *create_label(const char *text, const char *css_class) { @@ -23,6 +88,126 @@ static GtkWidget *create_label(const char *text, const char *css_class) { return label; } +static void install_split_drop_css(GtkWidget *widget) { + static gboolean installed = FALSE; + + if (installed) { + return; + } + + GtkCssProvider *provider = gtk_css_provider_new(); + gtk_css_provider_load_from_string( + provider, + ".split-drop-highlight {" + " background: rgba(53, 132, 228, 0.24);" + " border: 2px solid rgba(53, 132, 228, 0.72);" + " border-radius: 10px;" + "}" + ".pane-drag-preview {" + " background: rgba(98, 99, 107, 0.96);" + " border-radius: 9px;" + " padding: 8px 12px;" + " box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);" + "}" + ); + gtk_style_context_add_provider_for_display( + gtk_widget_get_display(widget), + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + ); + g_object_unref(provider); + installed = TRUE; +} + +static void clear_content_area(KosmosMainWindow *self) { + self->suppress_tab_detach = TRUE; + + if (self->pane_views != NULL) { + g_hash_table_remove_all(self->pane_views); + } + g_clear_pointer(&self->layout_signature, g_free); + + GtkWidget *child = gtk_widget_get_first_child(self->content_area); + + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(self->content_area), child); + child = next; + } + + self->suppress_tab_detach = FALSE; +} + +static void set_uint64_data(GObject *object, const char *key, guint64 value) { + guint64 *stored_value = g_new(guint64, 1); + *stored_value = value; + g_object_set_data_full(object, key, stored_value, g_free); +} + +static gboolean get_uint64_data(GObject *object, const char *key, guint64 *value) { + guint64 *stored_value = g_object_get_data(object, key); + + if (stored_value == NULL) { + return FALSE; + } + + *value = *stored_value; + return TRUE; +} + +static void register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view) { + guint64 *key = g_new(guint64, 1); + *key = pane_id; + g_hash_table_replace(self->pane_views, key, view); +} + +static AdwTabView *pane_view_for(KosmosMainWindow *self, guint64 pane_id) { + return g_hash_table_lookup(self->pane_views, &pane_id); +} + +static void set_paned_ratio(KosmosMainWindow *self, const char *path, double ratio) { + double *stored_ratio = g_new(double, 1); + *stored_ratio = CLAMP(ratio, 0.05, 0.95); + g_hash_table_replace(self->paned_ratios, g_strdup(path), stored_ratio); +} + +static gboolean get_paned_ratio(KosmosMainWindow *self, const char *path, double *ratio) { + double *stored_ratio = g_hash_table_lookup(self->paned_ratios, path); + + if (stored_ratio == NULL) { + return FALSE; + } + + *ratio = *stored_ratio; + return TRUE; +} + +static void remember_paned_ratios_from_widget(KosmosMainWindow *self, GtkWidget *widget) { + if (GTK_IS_PANED(widget)) { + const char *path = g_object_get_data(G_OBJECT(widget), "pane-path"); + GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); + int size = orientation == GTK_ORIENTATION_HORIZONTAL + ? gtk_widget_get_width(widget) + : gtk_widget_get_height(widget); + + if (path != NULL && size > 1) { + set_paned_ratio(self, path, (double)gtk_paned_get_position(GTK_PANED(widget)) / size); + } + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + remember_paned_ratios_from_widget(self, child); + } +} + +static void remember_paned_ratios(KosmosMainWindow *self) { + if (self->content_area == NULL) { + return; + } + + remember_paned_ratios_from_widget(self, self->content_area); +} + static void clear_workspace_switcher(KosmosMainWindow *self) { GtkWidget *child = gtk_widget_get_first_child(self->workspace_switcher); @@ -61,6 +246,36 @@ static gboolean get_string_member(JsonObject *object, const char *name, const ch return TRUE; } +static gboolean get_double_member(JsonObject *object, const char *name, double *value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { + return FALSE; + } + + *value = json_node_get_double(node); + return TRUE; +} + +static JsonObject *get_object_member(JsonObject *object, const char *name) { + JsonNode *node = json_object_get_member(object, name); + + if (node == NULL || !JSON_NODE_HOLDS_OBJECT(node)) { + return NULL; + } + + return json_node_get_object(node); +} + +static JsonArray *get_array_member(JsonObject *object, const char *name) { + JsonNode *node = json_object_get_member(object, name); + + if (node == NULL || !JSON_NODE_HOLDS_ARRAY(node)) { + return NULL; + } + + return json_node_get_array(node); +} + static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_workspace_id) { JsonNode *node = json_object_get_member(result, "activeWorkspaceId"); if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { @@ -77,7 +292,17 @@ static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_work } static void set_status(KosmosMainWindow *self, const char *status) { - gtk_label_set_text(GTK_LABEL(self->status_label), status); + clear_content_area(self); + + if (status == NULL || status[0] == '\0') { + return; + } + + GtkWidget *label = create_label(status, "dim-label"); + gtk_widget_set_halign(label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(label, GTK_ALIGN_CENTER); + gtk_widget_set_vexpand(label, TRUE); + gtk_box_append(GTK_BOX(self->content_area), label); } static void set_error_status(KosmosMainWindow *self, const char *prefix, GError *error) { @@ -299,106 +524,1676 @@ static GtkWidget *update_workspace_button(KosmosMainWindow *self, JsonObject *wo return button; } -static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { - if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { - set_status(self, "Workspace state is unavailable."); +static JsonObject *find_active_workspace(JsonArray *workspaces, guint64 active_workspace_id, gboolean has_active_workspace) { + if (!has_active_workspace) { + return NULL; + } + + guint workspace_count = json_array_get_length(workspaces); + for (guint index = 0; index < workspace_count; index++) { + JsonNode *workspace_node = json_array_get_element(workspaces, index); + if (workspace_node == NULL || !JSON_NODE_HOLDS_OBJECT(workspace_node)) { + continue; + } + + JsonObject *workspace = json_node_get_object(workspace_node); + guint64 workspace_id = 0; + if (get_uint_member(workspace, "id", &workspace_id) && workspace_id == active_workspace_id) { + return workspace; + } + } + + return NULL; +} + +static gboolean append_layout_signature(GString *signature, JsonObject *node) { + const char *type = NULL; + if (!get_string_member(node, "type", &type)) { + return FALSE; + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { + return FALSE; + } + + g_string_append_printf(signature, "L:%" G_GUINT64_FORMAT, pane_id); + return TRUE; + } + + if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + JsonObject *first = get_object_member(node, "first"); + JsonObject *second = get_object_member(node, "second"); + if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return FALSE; + } + + g_string_append_printf(signature, "S:%s(", axis); + if (!append_layout_signature(signature, first)) { + return FALSE; + } + g_string_append_c(signature, ','); + if (!append_layout_signature(signature, second)) { + return FALSE; + } + g_string_append_c(signature, ')'); + return TRUE; + } + + return FALSE; +} + +static char *create_layout_signature(JsonObject *workspace) { + JsonObject *root = get_object_member(workspace, "root"); + if (root == NULL) { + return NULL; + } + + GString *signature = g_string_new(NULL); + if (!append_layout_signature(signature, root)) { + g_string_free(signature, TRUE); + return NULL; + } + + return g_string_free(signature, FALSE); +} + +static void collect_split_paths(JsonObject *node, const char *path, GHashTable *split_paths) { + const char *type = NULL; + if (!get_string_member(node, "type", &type)) { return; } - JsonObject *result = json_node_get_object(state); - JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); - if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { - set_status(self, "Workspace state is missing workspaces."); + if (g_strcmp0(type, "split") != 0) { return; } - guint64 active_workspace_id = 0; - gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); - JsonArray *workspaces = json_node_get_array(workspaces_node); - guint workspace_count = json_array_get_length(workspaces); - GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - GtkWidget *previous_button = NULL; + JsonObject *first = get_object_member(node, "first"); + JsonObject *second = get_object_member(node, "second"); + if (first == NULL || second == NULL) { + return; + } - for (guint index = 0; index < workspace_count; index++) { - JsonNode *workspace_node = json_array_get_element(workspaces, index); - if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { - JsonObject *workspace = json_node_get_object(workspace_node); - guint64 workspace_id = 0; + g_hash_table_add(split_paths, g_strdup(path)); - if (!get_uint_member(workspace, "id", &workspace_id)) { - continue; - } + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + collect_split_paths(first, first_path, split_paths); + collect_split_paths(second, second_path, split_paths); + g_free(first_path); + g_free(second_path); +} - guint64 *seen_id = g_new(guint64, 1); - *seen_id = workspace_id; - g_hash_table_add(seen_workspace_ids, seen_id); +static void prune_paned_ratios_for_root(KosmosMainWindow *self, JsonObject *root) { + GHashTable *split_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + collect_split_paths(root, "root", split_paths); - GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); - if (button != NULL) { - gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); - previous_button = button; - } + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + g_hash_table_iter_init(&iter, self->paned_ratios); + while (g_hash_table_iter_next(&iter, &key, &value)) { + if (!g_hash_table_contains(split_paths, key)) { + g_hash_table_iter_remove(&iter); } } - remove_stale_workspace_buttons(self, seen_workspace_ids); - gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), ensure_add_workspace_button(self, TRUE), previous_button); - g_hash_table_unref(seen_workspace_ids); + g_hash_table_unref(split_paths); +} - if (workspace_count == 0) { - set_status(self, "No workspaces open. Use the plus button in the header to open one."); +static void apply_server_state_or_show_error(KosmosMainWindow *self, JsonNode *state, GError *error, const char *error_prefix) { + if (error != NULL) { + set_error_status(self, error_prefix, error); return; } - set_status(self, has_active_workspace ? "Workspace state synced from kosmos-server." : "No active workspace selected."); + apply_workspace_state(self, state); } -static GtkWidget *create_workspace_switcher(KosmosMainWindow *self) { - self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_add_css_class(self->workspace_switcher, "linked"); - gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); - ensure_add_workspace_button(self, FALSE); +static void clear_pending_tab_activation(AdwTabView *view) { + g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); +} - return self->workspace_switcher; +static void set_pending_tab_activation(AdwTabView *view, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { + PendingTabActivation *activation = g_new(PendingTabActivation, 1); + activation->workspace_id = workspace_id; + activation->pane_id = pane_id; + activation->tab_id = tab_id; + + g_object_set_data_full(G_OBJECT(view), "pending-tab-activation", activation, g_free); } -static GtkWidget *create_header(KosmosMainWindow *self) { - GtkWidget *header = gtk_header_bar_new(); - gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), create_workspace_switcher(self)); +static void request_tab_activation(KosmosMainWindow *self, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { + if (!ensure_connected(self)) { + return; + } - return header; + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_activate_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); + apply_server_state_or_show_error(self, state, error, "Failed to activate tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } } -static void kosmos_main_window_init(KosmosMainWindow *self) { - self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); +static void tab_bar_pressed(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)gesture; + (void)n_press; + (void)x; + (void)y; - gtk_window_set_title(GTK_WINDOW(self), "Kosmos"); - gtk_window_set_default_size(GTK_WINDOW(self), 1000, 700); + TabBarActivation *activation = user_data; + g_object_set_data(G_OBJECT(activation->view), "tab-press-active", GINT_TO_POINTER(1)); +} - GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_window_set_child(GTK_WINDOW(self), root); - gtk_window_set_titlebar(GTK_WINDOW(self), create_header(self)); +static void deferred_tab_activation_free(DeferredTabActivation *activation) { + g_object_unref(activation->window); + g_object_unref(activation->view); + g_free(activation); +} - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16); - gtk_widget_set_margin_top(content, 64); - gtk_widget_set_margin_bottom(content, 64); - gtk_widget_set_margin_start(content, 64); - gtk_widget_set_margin_end(content, 64); - gtk_widget_set_vexpand(content, TRUE); - gtk_box_append(GTK_BOX(root), content); +static gboolean finish_pending_tab_activation(gpointer user_data) { + DeferredTabActivation *activation = user_data; + AdwTabView *view = activation->view; + PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); + PendingTabActivation pending_copy = {0}; + + if (gtk_widget_get_root(GTK_WIDGET(view)) == NULL || pending == NULL || adw_tab_view_get_is_transferring_page(view)) { + clear_pending_tab_activation(view); + return G_SOURCE_REMOVE; + } + + pending_copy = *pending; + clear_pending_tab_activation(view); + request_tab_activation(activation->window, pending_copy.workspace_id, pending_copy.pane_id, pending_copy.tab_id); + + return G_SOURCE_REMOVE; +} + +static void tab_bar_released(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)gesture; + (void)n_press; + (void)x; + (void)y; + + TabBarActivation *activation = user_data; + AdwTabView *view = activation->view; + PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); + + g_object_set_data(G_OBJECT(view), "tab-press-active", NULL); + if (pending == NULL || adw_tab_view_get_is_transferring_page(view)) { + clear_pending_tab_activation(view); + return; + } + + DeferredTabActivation *deferred = g_new(DeferredTabActivation, 1); + deferred->window = g_object_ref(activation->window); + deferred->view = g_object_ref(view); + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_pending_tab_activation, deferred, (GDestroyNotify)deferred_tab_activation_free); +} + +static void tab_transfer_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { + (void)pspec; + (void)user_data; + + AdwTabView *view = ADW_TAB_VIEW(object); + if (adw_tab_view_get_is_transferring_page(view)) { + clear_pending_tab_activation(view); + } +} + +static void tab_detached(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + (void)page; + (void)position; + (void)user_data; + + clear_pending_tab_activation(view); +} + +static void activate_selected_tab(GObject *object, GParamSpec *pspec, gpointer user_data) { + (void)pspec; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + if (self->applying_server_state) { + return; + } + + AdwTabView *view = ADW_TAB_VIEW(object); + if (adw_tab_view_get_is_transferring_page(view)) { + return; + } + + AdwTabPage *page = adw_tab_view_get_selected_page(view); + + if (page == NULL || !ensure_connected(self)) { + return; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { + return; + } + + if (g_object_get_data(G_OBJECT(view), "tab-press-active") != NULL) { + set_pending_tab_activation(view, workspace_id, pane_id, tab_id); + return; + } + + request_tab_activation(self, workspace_id, pane_id, tab_id); +} + +static gboolean close_tab(AdwTabView *view, AdwTabPage *page, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + + if (self->applying_server_state) { + adw_tab_view_close_page_finish(view, page, TRUE); + return GDK_EVENT_STOP; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || + !ensure_connected(self)) { + adw_tab_view_close_page_finish(view, page, FALSE); + return GDK_EVENT_STOP; + } + + GError *error = NULL; + JsonNode *state = NULL; + gboolean closed = kosmos_ipc_client_close_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); + adw_tab_view_close_page_finish(view, page, closed); + apply_server_state_or_show_error(self, state, error, "Failed to close tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } + + return GDK_EVENT_STOP; +} + +static void reorder_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + clear_pending_tab_activation(view); + + if (self->applying_server_state) { + return; + } + + if (adw_tab_view_get_is_transferring_page(view)) { + return; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || + !ensure_connected(self)) { + kosmos_main_window_refresh_workspace_state(self); + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_reorder_tab(self->ipc_client, workspace_id, pane_id, tab_id, (guint)position, &state, NULL, &error); + + if (error != NULL) { + g_clear_error(&error); + kosmos_main_window_refresh_workspace_state(self); + return; + } + + apply_workspace_state(self, state); + if (state != NULL) { + json_node_unref(state); + } +} + +static void detached_tab_split_free(DetachedTabSplit *split) { + g_object_unref(split->window); + g_free(split); +} + +static void clear_detached_tab_transfer(KosmosMainWindow *self) { + if (self->detached_tab_window != NULL) { + gtk_window_destroy(self->detached_tab_window); + } + + g_clear_object(&self->detached_tab_window); + g_clear_object(&self->detached_tab_sink); +} + +static void request_split_tab( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + guint64 tab_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + if (!ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_split_tab( + self->ipc_client, + workspace_id, + pane_id, + target_pane_id, + tab_id, + axis, + new_pane_first, + &state, + NULL, + &error + ); + apply_server_state_or_show_error(self, state, error, "Failed to split tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static void request_move_pane( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + if (!ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_move_pane( + self->ipc_client, + workspace_id, + pane_id, + target_pane_id, + axis, + new_pane_first, + &state, + NULL, + &error + ); + apply_server_state_or_show_error(self, state, error, "Failed to move pane"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static gboolean finish_detached_tab_split(gpointer user_data) { + DetachedTabSplit *split = user_data; + KosmosMainWindow *self = split->window; + + request_split_tab( + self, + split->workspace_id, + split->pane_id, + split->target_pane_id, + split->tab_id, + split->axis, + split->new_pane_first + ); + + clear_detached_tab_transfer(self); + return G_SOURCE_REMOVE; +} + +static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + (void)position; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + gboolean is_split_target = g_object_get_data(G_OBJECT(view), "split-drop-target") != NULL; + + if (!self->splitting_detached_tab && !is_split_target) { + return; + } + + self->splitting_detached_tab = FALSE; + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { + kosmos_main_window_refresh_workspace_state(self); + return; + } + + guint64 target_pane_id = pane_id; + get_uint64_data(G_OBJECT(view), "target-pane-id", &target_pane_id); + + gpointer axis_data = g_object_get_data(G_OBJECT(view), "split-axis"); + KosmosIpcSplitAxis axis = axis_data == NULL + ? KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL + : (KosmosIpcSplitAxis)(GPOINTER_TO_INT(axis_data) - 1); + + gpointer new_pane_first_data = g_object_get_data(G_OBJECT(view), "split-new-pane-first"); + gboolean new_pane_first = new_pane_first_data == NULL + ? FALSE + : GPOINTER_TO_INT(new_pane_first_data) - 1; + + DetachedTabSplit *split = g_new(DetachedTabSplit, 1); + split->window = g_object_ref(self); + split->workspace_id = workspace_id; + split->pane_id = pane_id; + split->target_pane_id = target_pane_id; + split->tab_id = tab_id; + split->axis = axis; + split->new_pane_first = new_pane_first; + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); +} + +static AdwTabView *create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + clear_pending_tab_activation(view); + clear_detached_tab_transfer(self); + + GtkWidget *window = adw_window_new(); + AdwTabView *sink = adw_tab_view_new(); + + self->splitting_detached_tab = TRUE; + self->detached_tab_window = GTK_WINDOW(g_object_ref_sink(window)); + self->detached_tab_sink = ADW_TAB_VIEW(g_object_ref_sink(sink)); + + g_object_set_data(G_OBJECT(sink), "split-drop-target", GINT_TO_POINTER(1)); + gtk_window_set_child(self->detached_tab_window, GTK_WIDGET(sink)); + gtk_window_set_transient_for(self->detached_tab_window, GTK_WINDOW(self)); + g_signal_connect(sink, "page-attached", G_CALLBACK(split_attached_tab), self); + gtk_window_present(self->detached_tab_window); + + return sink; +} + +static void open_blank_tab(GtkButton *button, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + + guint64 workspace_id = 0; + guint64 pane_id = 0; + if (!get_uint64_data(G_OBJECT(button), "workspace-id", &workspace_id) || + !get_uint64_data(G_OBJECT(button), "pane-id", &pane_id) || + !ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_open_tab( + self->ipc_client, + workspace_id, + pane_id, + "Blank", + KOSMOS_IPC_TAB_KIND_BLANK, + &state, + NULL, + &error + ); + apply_server_state_or_show_error(self, state, error, "Failed to open tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static GtkWidget *create_new_tab_button(guint64 workspace_id, guint64 pane_id) { + GtkWidget *button = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_set_tooltip_text(button, "Open blank tab"); + set_uint64_data(G_OBJECT(button), "workspace-id", workspace_id); + set_uint64_data(G_OBJECT(button), "pane-id", pane_id); + + return button; +} + +static GtkWidget *create_tab_content(const char *kind) { + (void)kind; + + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + return content; +} + +static void configure_tab_page(AdwTabPage *page, guint64 workspace_id, guint64 pane_id, guint64 tab_id, const char *title) { + adw_tab_page_set_title(page, title); + adw_tab_page_set_tooltip(page, title); + set_uint64_data(G_OBJECT(page), "workspace-id", workspace_id); + set_uint64_data(G_OBJECT(page), "pane-id", pane_id); + set_uint64_data(G_OBJECT(page), "tab-id", tab_id); +} + +static void add_tab_bar_activation_controller(KosmosMainWindow *self, GtkWidget *tab_bar, AdwTabView *tab_view) { + GtkGesture *gesture = gtk_gesture_click_new(); + TabBarActivation *activation = g_new(TabBarActivation, 1); + + activation->window = self; + activation->view = tab_view; + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); + g_object_set_data_full(G_OBJECT(gesture), "tab-bar-activation", activation, g_free); + g_signal_connect(gesture, "pressed", G_CALLBACK(tab_bar_pressed), activation); + g_signal_connect(gesture, "released", G_CALLBACK(tab_bar_released), activation); + gtk_widget_add_controller(tab_bar, GTK_EVENT_CONTROLLER(gesture)); +} + +static void set_split_drop_highlight(GtkDropControllerMotion *controller, gboolean highlighted) { + GtkWidget *highlight = g_object_get_data(G_OBJECT(controller), "split-drop-highlight"); + + if (highlight != NULL) { + gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); + } +} + +static void set_split_zone_highlight(GtkWidget *zone, gboolean highlighted) { + GtkWidget *highlight = g_object_get_data(G_OBJECT(zone), "split-drop-highlight"); + + if (highlight != NULL) { + gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); + } +} + +static void set_pane_drag_highlight(KosmosMainWindow *self, GtkWidget *zone) { + GtkWidget *current_zone = g_object_get_data(G_OBJECT(self), "pane-drag-highlight-zone"); + + if (current_zone == zone) { + return; + } + + if (current_zone != NULL) { + set_split_zone_highlight(current_zone, FALSE); + } + + if (zone != NULL) { + set_split_zone_highlight(zone, TRUE); + } + + g_object_set_data(G_OBJECT(self), "pane-drag-highlight-zone", zone); +} + +static gboolean split_zone_contains_point(GtkWidget *zone, GtkWidget *source, double source_x, double source_y) { + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)source_x, (float)source_y); + graphene_point_t zone_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + + if (!gtk_widget_compute_point(source, zone, &source_point, &zone_point)) { + return FALSE; + } + + return zone_point.x >= 0.0f && + zone_point.y >= 0.0f && + zone_point.x < gtk_widget_get_width(zone) && + zone_point.y < gtk_widget_get_height(zone); +} + +static gboolean find_split_zone_at( + GtkWidget *widget, + GtkWidget *source, + double source_x, + double source_y, + guint64 source_pane_id, + GtkWidget **zone_widget, + SplitDropZone *zone +) { + if (!gtk_widget_get_visible(widget)) { + return FALSE; + } + + SplitDropZone *candidate = g_object_get_data(G_OBJECT(widget), "split-drop-zone"); + if (candidate != NULL && + candidate->target_pane_id != source_pane_id && + split_zone_contains_point(widget, source, source_x, source_y)) { + *zone_widget = widget; + *zone = *candidate; + return TRUE; + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + if (find_split_zone_at(child, source, source_x, source_y, source_pane_id, zone_widget, zone)) { + return TRUE; + } + } + + return FALSE; +} + +static void show_pane_drag_preview(PaneDrag *drag); +static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y); + +static void pane_drag_begin(GtkGestureDrag *gesture, double start_x, double start_y, gpointer user_data) { + PaneDrag *drag = user_data; + + drag->active = FALSE; + if (!pane_drag_start_is_valid(drag, start_x, start_y)) { + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED); + return; + } + + drag->start_x = start_x; + drag->start_y = start_y; + set_pane_drag_highlight(drag->window, NULL); +} + +static gboolean pane_drag_find_zone(PaneDrag *drag, double x, double y, GtkWidget **zone_widget, SplitDropZone *zone) { + return find_split_zone_at( + drag->window->content_area, + drag->source, + x, + y, + drag->pane_id, + zone_widget, + zone + ); +} + +static gboolean picked_widget_is_or_contains(GtkWidget *picked, GtkWidget *widget) { + if (widget == NULL || picked == NULL) { + return FALSE; + } + + return picked == widget || (picked != NULL && gtk_widget_is_ancestor(picked, widget)); +} + +static gboolean pane_drag_starts_on_action_area(PaneDrag *drag, double x, double y) { + if (drag->excluded_widget == NULL) { + return FALSE; + } + + graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); + graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point) && x >= source_point.x - 8.0) { + return TRUE; + } + + return split_zone_contains_point(drag->excluded_widget, drag->source, x, y); +} + +static gboolean pane_drag_starts_in_tab_region(PaneDrag *drag, double x, double y) { + if (x < 0.0 || y < 0.0 || x >= gtk_widget_get_width(drag->source) || y >= gtk_widget_get_height(drag->source)) { + return FALSE; + } + + double action_start = gtk_widget_get_width(drag->source); + if (drag->excluded_widget != NULL) { + graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); + graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point)) { + action_start = source_point.x; + } + } + + double tab_region_end = MIN(action_start - 8.0, 260.0); + return x <= tab_region_end; +} + +static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y) { + GtkWidget *picked = gtk_widget_pick(drag->source, x, y, GTK_PICK_DEFAULT); + + if (picked == NULL || picked == drag->source) { + return FALSE; + } + + if (picked_widget_is_or_contains(picked, drag->excluded_widget) || pane_drag_starts_on_action_area(drag, x, y)) { + return FALSE; + } + + return pane_drag_starts_in_tab_region(drag, x, y); +} + +static gboolean pane_drag_offset_is_significant(double offset_x, double offset_y) { + return offset_x * offset_x + offset_y * offset_y >= 64.0; +} + +static GtkWidget *create_pane_drag_preview(const char *title) { + GtkWidget *preview = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + GtkWidget *label = gtk_label_new(title == NULL || title[0] == '\0' ? "Blank" : title); + GtkWidget *close_icon = gtk_image_new_from_icon_name("window-close-symbolic"); + + gtk_widget_add_css_class(preview, "pane-drag-preview"); + gtk_widget_set_can_target(preview, FALSE); + gtk_widget_set_halign(preview, GTK_ALIGN_START); + gtk_widget_set_valign(preview, GTK_ALIGN_START); + gtk_widget_set_size_request(preview, 170, -1); + gtk_label_set_xalign(GTK_LABEL(label), 0.5f); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_can_target(label, FALSE); + gtk_widget_set_can_target(close_icon, FALSE); + + gtk_box_append(GTK_BOX(preview), label); + gtk_box_append(GTK_BOX(preview), close_icon); + + return preview; +} + +static void set_pane_drag_preview_position(PaneDrag *drag, double x, double y) { + if (drag->preview == NULL) { + return; + } + + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t overlay_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (!gtk_widget_compute_point(drag->source, drag->window->content_overlay, &source_point, &overlay_point)) { + return; + } + + gtk_widget_set_margin_start(drag->preview, MAX(0, (int)overlay_point.x - 85)); + gtk_widget_set_margin_top(drag->preview, MAX(0, (int)overlay_point.y - 18)); +} + +static void show_pane_drag_preview(PaneDrag *drag) { + if (drag->preview != NULL) { + return; + } + + drag->preview = create_pane_drag_preview(drag->title); + gtk_overlay_add_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); + set_pane_drag_preview_position(drag, drag->start_x, drag->start_y); +} + +static void clear_pane_drag_preview(PaneDrag *drag) { + if (drag->preview == NULL) { + return; + } + + gtk_overlay_remove_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); + drag->preview = NULL; +} + +static void pane_drag_update(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { + (void)gesture; + + PaneDrag *drag = user_data; + GtkWidget *zone_widget = NULL; + SplitDropZone zone = {0}; + double x = drag->start_x + offset_x; + double y = drag->start_y + offset_y; + + if (!drag->active) { + if (!pane_drag_offset_is_significant(offset_x, offset_y)) { + return; + } + + drag->active = TRUE; + clear_pending_tab_activation(drag->view); + g_object_set_data(G_OBJECT(drag->view), "tab-press-active", NULL); + show_pane_drag_preview(drag); + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); + } + + set_pane_drag_preview_position(drag, x, y); + if (pane_drag_find_zone(drag, x, y, &zone_widget, &zone)) { + set_pane_drag_highlight(drag->window, zone_widget); + } else { + set_pane_drag_highlight(drag->window, NULL); + } +} + +static void pane_drag_end(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { + (void)gesture; + + PaneDrag *drag = user_data; + GtkWidget *zone_widget = NULL; + SplitDropZone zone = {0}; + gboolean has_zone = pane_drag_find_zone(drag, drag->start_x + offset_x, drag->start_y + offset_y, &zone_widget, &zone); + + if (!drag->active) { + return; + } + + drag->active = FALSE; + set_pane_drag_highlight(drag->window, NULL); + clear_pane_drag_preview(drag); + if (!has_zone) { + return; + } + + request_move_pane( + drag->window, + drag->workspace_id, + drag->pane_id, + zone.target_pane_id, + zone.axis, + zone.new_pane_first + ); +} + +static void pane_drag_free(PaneDrag *drag) { + clear_pane_drag_preview(drag); + g_free(drag->title); + g_free(drag); +} + +static void remove_single_tab_pane_drag(GtkWidget *widget) { + GtkEventController *controller = g_object_get_data(G_OBJECT(widget), "pane-drag-controller"); + + if (controller == NULL) { + return; + } + + g_object_set_data(G_OBJECT(widget), "pane-drag-controller", NULL); + gtk_widget_remove_controller(widget, controller); +} + +static void add_single_tab_pane_drag( + KosmosMainWindow *self, + GtkWidget *widget, + GtkWidget *excluded_widget, + AdwTabView *tab_view, + guint64 workspace_id, + guint64 pane_id, + const char *title +) { + GtkGesture *gesture = gtk_gesture_drag_new(); + PaneDrag *drag = g_new0(PaneDrag, 1); + + remove_single_tab_pane_drag(widget); + + drag->window = self; + drag->view = tab_view; + drag->source = widget; + drag->excluded_widget = excluded_widget; + drag->title = g_strdup(title); + drag->workspace_id = workspace_id; + drag->pane_id = pane_id; + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); + g_object_set_data_full(G_OBJECT(gesture), "pane-drag", drag, (GDestroyNotify)pane_drag_free); + g_signal_connect(gesture, "drag-begin", G_CALLBACK(pane_drag_begin), drag); + g_signal_connect(gesture, "drag-update", G_CALLBACK(pane_drag_update), drag); + g_signal_connect(gesture, "drag-end", G_CALLBACK(pane_drag_end), drag); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(gesture)); + g_object_set_data(G_OBJECT(widget), "pane-drag-controller", gesture); +} + +static void configure_single_tab_pane_drag( + KosmosMainWindow *self, + GtkWidget *tab_bar, + GtkWidget *new_tab_button, + AdwTabView *tab_view, + guint64 workspace_id, + guint64 pane_id, + int tab_count, + AdwTabPage *active_page +) { + if (tab_count == 1 && active_page != NULL) { + add_single_tab_pane_drag( + self, + tab_bar, + new_tab_button, + tab_view, + workspace_id, + pane_id, + adw_tab_page_get_title(active_page) + ); + return; + } + + remove_single_tab_pane_drag(tab_bar); +} + +static AdwTabPage *tab_page_for_id(AdwTabView *view, guint64 tab_id) { + int page_count = adw_tab_view_get_n_pages(view); + + for (int index = 0; index < page_count; index++) { + AdwTabPage *page = adw_tab_view_get_nth_page(view, index); + guint64 page_tab_id = 0; + if (get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) && page_tab_id == tab_id) { + return page; + } + } + + return NULL; +} + +static JsonObject *get_tab_snapshot(JsonArray *tabs, guint index) { + JsonNode *tab_node = json_array_get_element(tabs, index); + + if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { + return NULL; + } + + return json_node_get_object(tab_node); +} + +static gboolean tab_view_matches_snapshot_prefix(AdwTabView *view, JsonArray *tabs) { + int page_count = adw_tab_view_get_n_pages(view); + + if (json_array_get_length(tabs) < (guint)page_count) { + return FALSE; + } + + for (int index = 0; index < page_count; index++) { + AdwTabPage *page = adw_tab_view_get_nth_page(view, index); + JsonObject *tab = get_tab_snapshot(tabs, (guint)index); + guint64 page_tab_id = 0; + guint64 snapshot_tab_id = 0; + + if (tab == NULL || + !get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) || + !get_uint_member(tab, "id", &snapshot_tab_id) || + page_tab_id != snapshot_tab_id) { + return FALSE; + } + } + + return TRUE; +} + +static gboolean tab_view_matches_snapshot_exact(AdwTabView *view, JsonArray *tabs) { + return json_array_get_length(tabs) == (guint)adw_tab_view_get_n_pages(view) && + tab_view_matches_snapshot_prefix(view, tabs); +} + +static gboolean update_tab_view_from_pane_view( + KosmosMainWindow *self, + AdwTabView *view, + JsonObject *pane, + guint64 workspace_id, + guint64 active_pane_id, + gboolean allow_append +) { + guint64 pane_id = 0; + guint64 active_tab_id = 0; + JsonArray *tabs = get_array_member(pane, "tabs"); + + if (!get_uint_member(pane, "id", &pane_id) || !get_uint_member(pane, "activeTabId", &active_tab_id) || tabs == NULL) { + return FALSE; + } + + if (allow_append) { + if (!tab_view_matches_snapshot_prefix(view, tabs)) { + return FALSE; + } + } else if (!tab_view_matches_snapshot_exact(view, tabs)) { + return FALSE; + } + + gboolean was_applying = self->applying_server_state; + self->applying_server_state = TRUE; + + AdwTabPage *active_page = NULL; + guint tab_count = json_array_get_length(tabs); + for (guint index = 0; index < tab_count; index++) { + JsonObject *tab = get_tab_snapshot(tabs, index); + guint64 tab_id = 0; + const char *title = NULL; + const char *kind = NULL; + if (tab == NULL || !get_uint_member(tab, "id", &tab_id) || !get_string_member(tab, "title", &title)) { + continue; + } + get_string_member(tab, "kind", &kind); + + AdwTabPage *page = tab_page_for_id(view, tab_id); + if (page == NULL) { + if (!allow_append) { + self->applying_server_state = was_applying; + return FALSE; + } + + page = adw_tab_view_append(view, create_tab_content(kind)); + } + + configure_tab_page(page, workspace_id, pane_id, tab_id, title); + if (tab_id == active_tab_id) { + active_page = page; + } + } + + if (active_page != NULL && adw_tab_view_get_selected_page(view) != active_page) { + adw_tab_view_set_selected_page(view, active_page); + } + + (void)active_pane_id; + + GtkWidget *tab_bar = g_object_get_data(G_OBJECT(view), "tab-bar"); + GtkWidget *new_tab_button = g_object_get_data(G_OBJECT(view), "new-tab-button"); + if (tab_bar != NULL && new_tab_button != NULL) { + configure_single_tab_pane_drag( + self, + tab_bar, + new_tab_button, + view, + workspace_id, + pane_id, + (int)tab_count, + active_page + ); + } + + self->applying_server_state = was_applying; + register_pane_view(self, pane_id, view); + return TRUE; +} + +static gboolean update_tab_view_from_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, guint64 active_pane_id) { + guint64 pane_id = 0; + if (!get_uint_member(pane, "id", &pane_id)) { + return FALSE; + } + + AdwTabView *view = pane_view_for(self, pane_id); + if (view == NULL) { + return FALSE; + } + + return update_tab_view_from_pane_view(self, view, pane, workspace_id, active_pane_id, TRUE); +} + +static gboolean update_pane_node_in_place(KosmosMainWindow *self, JsonObject *node, guint64 workspace_id, guint64 active_pane_id) { + const char *type = NULL; + if (!get_string_member(node, "type", &type)) { + return FALSE; + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = get_object_member(node, "pane"); + return pane != NULL && update_tab_view_from_pane(self, pane, workspace_id, active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + JsonObject *first = get_object_member(node, "first"); + JsonObject *second = get_object_member(node, "second"); + return first != NULL && + second != NULL && + update_pane_node_in_place(self, first, workspace_id, active_pane_id) && + update_pane_node_in_place(self, second, workspace_id, active_pane_id); + } + + return FALSE; +} + +static gboolean update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace) { + guint64 workspace_id = 0; + guint64 active_pane_id = 0; + JsonObject *root = get_object_member(workspace, "root"); + + if (!get_uint_member(workspace, "id", &workspace_id) || + !get_uint_member(workspace, "activePaneId", &active_pane_id) || + root == NULL) { + return FALSE; + } + + return update_pane_node_in_place(self, root, workspace_id, active_pane_id); +} + +static void show_split_drop_highlight(GtkDropControllerMotion *controller, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + set_split_drop_highlight(controller, TRUE); +} + +static void hide_split_drop_highlight(GtkDropControllerMotion *controller, gpointer user_data) { + (void)user_data; + + set_split_drop_highlight(controller, FALSE); +} + +static GtkWidget *create_split_drop_highlight(void) { + GtkWidget *highlight = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(highlight, "split-drop-highlight"); + gtk_widget_set_can_target(highlight, FALSE); + gtk_widget_set_hexpand(highlight, TRUE); + gtk_widget_set_vexpand(highlight, TRUE); + gtk_widget_set_opacity(highlight, 0.0); + + return highlight; +} + +static GtkWidget *create_split_drop_target( + KosmosMainWindow *self, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + GtkWidget *target = gtk_overlay_new(); + GtkWidget *drop_receiver = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *highlight = create_split_drop_highlight(); + GtkEventController *motion = GTK_EVENT_CONTROLLER(gtk_drop_controller_motion_new()); + AdwTabBar *tab_bar = adw_tab_bar_new(); + AdwTabView *tab_view = adw_tab_view_new(); + SplitDropZone *zone = g_new(SplitDropZone, 1); + + adw_tab_bar_set_view(tab_bar, tab_view); + gtk_widget_set_visible(GTK_WIDGET(tab_bar), FALSE); + gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_opacity(GTK_WIDGET(tab_view), 0.01); + gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_bar)); + gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_view)); + gtk_overlay_set_child(GTK_OVERLAY(target), drop_receiver); + gtk_overlay_add_overlay(GTK_OVERLAY(target), highlight); + + g_object_set_data(G_OBJECT(tab_view), "split-drop-target", GINT_TO_POINTER(1)); + set_uint64_data(G_OBJECT(tab_view), "target-pane-id", target_pane_id); + g_object_set_data(G_OBJECT(tab_view), "split-axis", GINT_TO_POINTER(axis + 1)); + g_object_set_data(G_OBJECT(tab_view), "split-new-pane-first", GINT_TO_POINTER((new_pane_first ? 1 : 0) + 1)); + g_signal_connect(tab_view, "page-attached", G_CALLBACK(split_attached_tab), self); + + zone->target_pane_id = target_pane_id; + zone->axis = axis; + zone->new_pane_first = new_pane_first; + g_object_set_data_full(G_OBJECT(target), "split-drop-zone", zone, g_free); + g_object_set_data(G_OBJECT(target), "split-drop-highlight", highlight); + + g_object_set_data(G_OBJECT(motion), "split-drop-highlight", highlight); + g_signal_connect(motion, "enter", G_CALLBACK(show_split_drop_highlight), NULL); + g_signal_connect(motion, "motion", G_CALLBACK(show_split_drop_highlight), NULL); + g_signal_connect(motion, "leave", G_CALLBACK(hide_split_drop_highlight), NULL); + gtk_widget_add_controller(target, motion); + + return target; +} + +static void add_split_drop_zone( + GtkOverlay *overlay, + GtkWidget *target, + GtkAlign halign, + GtkAlign valign, + int width, + int height +) { + gtk_widget_set_halign(target, halign); + gtk_widget_set_valign(target, valign); + gtk_widget_set_size_request(target, width, height); + gtk_overlay_add_overlay(overlay, target); +} + +static GtkWidget *create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id) { + GtkWidget *overlay = gtk_overlay_new(); + gtk_widget_set_hexpand(overlay, TRUE); + gtk_widget_set_vexpand(overlay, TRUE); + gtk_overlay_set_child(GTK_OVERLAY(overlay), GTK_WIDGET(tab_view)); + + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, TRUE), + GTK_ALIGN_START, + GTK_ALIGN_FILL, + 96, + -1 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, FALSE), + GTK_ALIGN_END, + GTK_ALIGN_FILL, + 96, + -1 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, TRUE), + GTK_ALIGN_FILL, + GTK_ALIGN_START, + -1, + 96 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, FALSE), + GTK_ALIGN_FILL, + GTK_ALIGN_END, + -1, + 96 + ); + + return overlay; +} + +static GtkWidget *create_tabbed_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, gboolean is_active_pane) { + guint64 pane_id = 0; + guint64 active_tab_id = 0; + JsonArray *tabs = get_array_member(pane, "tabs"); + + if (!get_uint_member(pane, "id", &pane_id) || !get_uint_member(pane, "activeTabId", &active_tab_id) || tabs == NULL) { + return create_label("Invalid pane snapshot.", "error"); + } + + GtkWidget *container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(container, TRUE); + gtk_widget_set_vexpand(container, TRUE); + gtk_widget_set_size_request(container, KOSMOS_MIN_PANE_WIDTH, KOSMOS_MIN_PANE_HEIGHT); + + (void)is_active_pane; + + AdwTabView *tab_view = adw_tab_view_new(); + AdwTabBar *tab_bar = adw_tab_bar_new(); + GtkWidget *new_tab_button = create_new_tab_button(workspace_id, pane_id); + adw_tab_bar_set_view(tab_bar, tab_view); + adw_tab_bar_set_end_action_widget(tab_bar, new_tab_button); + adw_tab_bar_set_autohide(tab_bar, FALSE); + adw_tab_bar_set_expand_tabs(tab_bar, FALSE); + adw_tab_view_set_shortcuts( + tab_view, + ADW_TAB_VIEW_SHORTCUT_CONTROL_TAB | + ADW_TAB_VIEW_SHORTCUT_CONTROL_SHIFT_TAB | + ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_UP | + ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_DOWN | + ADW_TAB_VIEW_SHORTCUT_ALT_DIGITS | + ADW_TAB_VIEW_SHORTCUT_ALT_ZERO + ); + gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); + + guint tab_count = json_array_get_length(tabs); + AdwTabPage *active_page = NULL; + for (guint index = 0; index < tab_count; index++) { + JsonNode *tab_node = json_array_get_element(tabs, index); + if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { + continue; + } + + JsonObject *tab = json_node_get_object(tab_node); + guint64 tab_id = 0; + const char *title = NULL; + const char *kind = NULL; + if (!get_uint_member(tab, "id", &tab_id) || !get_string_member(tab, "title", &title)) { + continue; + } + get_string_member(tab, "kind", &kind); + + GtkWidget *content = create_tab_content(kind); + AdwTabPage *page = adw_tab_view_append(tab_view, content); + configure_tab_page(page, workspace_id, pane_id, tab_id, title); + + if (tab_id == active_tab_id) { + active_page = page; + } + } + + if (active_page != NULL) { + adw_tab_view_set_selected_page(tab_view, active_page); + } + + register_pane_view(self, pane_id, tab_view); + set_uint64_data(G_OBJECT(container), "pane-id", pane_id); + g_object_set_data(G_OBJECT(container), "tab-view", tab_view); + g_object_set_data(G_OBJECT(tab_view), "pane-container", container); + g_object_set_data(G_OBJECT(tab_view), "tab-bar", GTK_WIDGET(tab_bar)); + g_object_set_data(G_OBJECT(tab_view), "new-tab-button", new_tab_button); + + add_tab_bar_activation_controller(self, GTK_WIDGET(tab_bar), tab_view); + configure_single_tab_pane_drag( + self, + GTK_WIDGET(tab_bar), + new_tab_button, + tab_view, + workspace_id, + pane_id, + (int)tab_count, + active_page + ); + + gtk_box_append(GTK_BOX(container), GTK_WIDGET(tab_bar)); + gtk_box_append(GTK_BOX(container), create_split_overlay(self, tab_view, pane_id)); + g_signal_connect(tab_view, "notify::selected-page", G_CALLBACK(activate_selected_tab), self); + g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), NULL); + g_signal_connect(tab_view, "close-page", G_CALLBACK(close_tab), self); + g_signal_connect(tab_view, "create-window", G_CALLBACK(create_split_sink_for_detached_tab), self); + g_signal_connect(tab_view, "page-detached", G_CALLBACK(tab_detached), NULL); + g_signal_connect(tab_view, "page-reordered", G_CALLBACK(reorder_tab), self); + g_signal_connect(new_tab_button, "clicked", G_CALLBACK(open_blank_tab), self); + + return container; +} + +static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer user_data) { + (void)frame_clock; + + PanedRatio *ratio = user_data; + int size = ratio->orientation == GTK_ORIENTATION_HORIZONTAL + ? gtk_widget_get_width(widget) + : gtk_widget_get_height(widget); + + if (size <= 1) { + return G_SOURCE_CONTINUE; + } + + gtk_paned_set_position(GTK_PANED(widget), (int)(size * ratio->ratio)); + return G_SOURCE_REMOVE; +} + +static void set_paned_ratio_after_layout(GtkWidget *paned, GtkOrientation orientation, double ratio) { + PanedRatio *ratio_data = g_new(PanedRatio, 1); + ratio_data->orientation = orientation; + ratio_data->ratio = CLAMP(ratio, 0.05, 0.95); + gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, g_free); +} + +static GtkWidget *create_pane_node( + KosmosMainWindow *self, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path, + GHashTable *reusable_panes +) { + const char *type = NULL; + if (!get_string_member(node, "type", &type)) { + return create_label("Invalid pane node.", "error"); + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { + return create_label("Invalid pane leaf.", "error"); + } + + GtkWidget *reusable_pane = reusable_panes == NULL ? NULL : g_hash_table_lookup(reusable_panes, &pane_id); + if (reusable_pane != NULL && gtk_widget_get_parent(reusable_pane) == NULL) { + AdwTabView *tab_view = g_object_get_data(G_OBJECT(reusable_pane), "tab-view"); + if (tab_view != NULL && update_tab_view_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { + return reusable_pane; + } + } + + return create_tabbed_pane(self, pane, workspace_id, pane_id == active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + double ratio = 0.5; + JsonObject *first = get_object_member(node, "first"); + JsonObject *second = get_object_member(node, "second"); + if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return create_label("Invalid pane split.", "error"); + } + + get_double_member(node, "ratio", &ratio); + get_paned_ratio(self, path, &ratio); + + GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 + ? GTK_ORIENTATION_VERTICAL + : GTK_ORIENTATION_HORIZONTAL; + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + GtkWidget *paned = gtk_paned_new(orientation); + gtk_widget_set_hexpand(paned, TRUE); + gtk_widget_set_vexpand(paned, TRUE); + gtk_paned_set_shrink_start_child(GTK_PANED(paned), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); + g_object_set_data_full(G_OBJECT(paned), "pane-path", g_strdup(path), g_free); + gtk_paned_set_start_child(GTK_PANED(paned), create_pane_node(self, first, workspace_id, active_pane_id, first_path, reusable_panes)); + gtk_paned_set_end_child(GTK_PANED(paned), create_pane_node(self, second, workspace_id, active_pane_id, second_path, reusable_panes)); + set_paned_ratio_after_layout(paned, orientation, ratio); + g_free(first_path); + g_free(second_path); + return paned; + } + + return create_label("Unsupported pane node.", "error"); +} + +static gboolean widget_is_pane_leaf(GtkWidget *widget, guint64 pane_id) { + guint64 widget_pane_id = 0; + + return get_uint64_data(G_OBJECT(widget), "pane-id", &widget_pane_id) && widget_pane_id == pane_id; +} + +static GtkWidget *reconcile_pane_node( + KosmosMainWindow *self, + GtkWidget *current, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path +) { + const char *type = NULL; + if (!get_string_member(node, "type", &type)) { + return create_label("Invalid pane node.", "error"); + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { + return create_label("Invalid pane leaf.", "error"); + } + + if (current != NULL && widget_is_pane_leaf(current, pane_id)) { + AdwTabView *tab_view = g_object_get_data(G_OBJECT(current), "tab-view"); + if (tab_view != NULL && update_tab_view_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { + return current; + } + } + + return create_tabbed_pane(self, pane, workspace_id, pane_id == active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + double ratio = 0.5; + JsonObject *first = get_object_member(node, "first"); + JsonObject *second = get_object_member(node, "second"); + if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return create_label("Invalid pane split.", "error"); + } + + get_double_member(node, "ratio", &ratio); + get_paned_ratio(self, path, &ratio); + + GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 + ? GTK_ORIENTATION_VERTICAL + : GTK_ORIENTATION_HORIZONTAL; + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + + if (!GTK_IS_PANED(current)) { + GtkWidget *created = create_pane_node(self, node, workspace_id, active_pane_id, path, NULL); + g_free(first_path); + g_free(second_path); + return created; + } + + gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); + gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); + g_object_set_data_full(G_OBJECT(current), "pane-path", g_strdup(path), g_free); + + GtkWidget *old_start = gtk_paned_get_start_child(GTK_PANED(current)); + GtkWidget *new_start = reconcile_pane_node(self, old_start, first, workspace_id, active_pane_id, first_path); + if (new_start != old_start) { + gtk_paned_set_start_child(GTK_PANED(current), new_start); + } + + GtkWidget *old_end = gtk_paned_get_end_child(GTK_PANED(current)); + GtkWidget *new_end = reconcile_pane_node(self, old_end, second, workspace_id, active_pane_id, second_path); + if (new_end != old_end) { + gtk_paned_set_end_child(GTK_PANED(current), new_end); + } + + set_paned_ratio_after_layout(current, orientation, ratio); + g_free(first_path); + g_free(second_path); + return current; + } + + return create_label("Unsupported pane node.", "error"); +} + +static void render_active_workspace(KosmosMainWindow *self, JsonObject *workspace) { + remember_paned_ratios(self); + + if (workspace == NULL) { + g_hash_table_remove_all(self->paned_ratios); + clear_content_area(self); + return; + } + + guint64 workspace_id = 0; + guint64 active_pane_id = 0; + JsonObject *root = get_object_member(workspace, "root"); + + if (!get_uint_member(workspace, "id", &workspace_id) || + !get_uint_member(workspace, "activePaneId", &active_pane_id) || + root == NULL) { + set_status(self, "Invalid workspace snapshot."); + return; + } + + prune_paned_ratios_for_root(self, root); + + GtkWidget *current_root = gtk_widget_get_first_child(self->content_area); + if (current_root != NULL) { + g_hash_table_remove_all(self->pane_views); + GtkWidget *pane_tree = reconcile_pane_node(self, current_root, root, workspace_id, active_pane_id, "root"); + if (pane_tree != current_root) { + gtk_box_remove(GTK_BOX(self->content_area), current_root); + gtk_box_append(GTK_BOX(self->content_area), pane_tree); + } + return; + } + + GtkWidget *pane_tree = create_pane_node(self, root, workspace_id, active_pane_id, "root", NULL); + gtk_box_append(GTK_BOX(self->content_area), pane_tree); +} + +static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { + if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { + set_status(self, "Workspace state is unavailable."); + return; + } + + JsonObject *result = json_node_get_object(state); + JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); + if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { + set_status(self, "Workspace state is missing workspaces."); + return; + } + + guint64 active_workspace_id = 0; + gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); + JsonArray *workspaces = json_node_get_array(workspaces_node); + guint workspace_count = json_array_get_length(workspaces); + JsonObject *active_workspace = find_active_workspace(workspaces, active_workspace_id, has_active_workspace); + GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + GtkWidget *previous_button = NULL; + + for (guint index = 0; index < workspace_count; index++) { + JsonNode *workspace_node = json_array_get_element(workspaces, index); + if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { + JsonObject *workspace = json_node_get_object(workspace_node); + guint64 workspace_id = 0; + + if (!get_uint_member(workspace, "id", &workspace_id)) { + continue; + } + + guint64 *seen_id = g_new(guint64, 1); + *seen_id = workspace_id; + g_hash_table_add(seen_workspace_ids, seen_id); + + GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); + if (button != NULL) { + gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); + previous_button = button; + } + } + } + + remove_stale_workspace_buttons(self, seen_workspace_ids); + gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), ensure_add_workspace_button(self, TRUE), previous_button); + g_hash_table_unref(seen_workspace_ids); + + (void)workspace_count; + if (active_workspace != NULL) { + char *layout_signature = create_layout_signature(active_workspace); + if (layout_signature != NULL && + self->layout_signature != NULL && + g_strcmp0(layout_signature, self->layout_signature) == 0 && + update_active_workspace_in_place(self, active_workspace)) { + g_free(layout_signature); + return; + } + + render_active_workspace(self, active_workspace); + g_clear_pointer(&self->layout_signature, g_free); + self->layout_signature = layout_signature; + return; + } + + render_active_workspace(self, active_workspace); +} + +static GtkWidget *create_workspace_switcher(KosmosMainWindow *self) { + self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_add_css_class(self->workspace_switcher, "linked"); + gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); + ensure_add_workspace_button(self, FALSE); + + return self->workspace_switcher; +} + +static GtkWidget *create_header(KosmosMainWindow *self) { + GtkWidget *header = gtk_header_bar_new(); + gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), create_workspace_switcher(self)); + + return header; +} + +static void kosmos_main_window_init(KosmosMainWindow *self) { + self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + self->paned_ratios = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + self->pane_views = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + install_split_drop_css(GTK_WIDGET(self)); + + gtk_window_set_title(GTK_WINDOW(self), "Kosmos"); + gtk_window_set_default_size(GTK_WINDOW(self), 1000, 700); + + GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_window_set_child(GTK_WINDOW(self), root); + gtk_window_set_titlebar(GTK_WINDOW(self), create_header(self)); + + self->content_overlay = gtk_overlay_new(); + gtk_widget_set_hexpand(self->content_overlay, TRUE); + gtk_widget_set_vexpand(self->content_overlay, TRUE); - self->status_label = create_label("Loading workspace state...", "dim-label"); - gtk_widget_set_halign(self->status_label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(self->status_label, GTK_ALIGN_CENTER); - gtk_widget_set_vexpand(self->status_label, TRUE); - gtk_box_append(GTK_BOX(content), self->status_label); + self->content_area = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(self->content_area, TRUE); + gtk_widget_set_vexpand(self->content_area, TRUE); + gtk_overlay_set_child(GTK_OVERLAY(self->content_overlay), self->content_area); + gtk_box_append(GTK_BOX(root), self->content_overlay); } static void kosmos_main_window_finalize(GObject *object) { KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(object); + clear_detached_tab_transfer(self); g_clear_object(&self->ipc_client); g_clear_pointer(&self->workspace_buttons, g_hash_table_unref); + g_clear_pointer(&self->paned_ratios, g_hash_table_unref); + g_clear_pointer(&self->pane_views, g_hash_table_unref); + g_clear_pointer(&self->layout_signature, g_free); G_OBJECT_CLASS(kosmos_main_window_parent_class)->finalize(object); } diff --git a/server/src/ipc/messages/pane.rs b/server/src/ipc/messages/pane.rs index 30980682..751d6abf 100644 --- a/server/src/ipc/messages/pane.rs +++ b/server/src/ipc/messages/pane.rs @@ -19,6 +19,16 @@ pub(crate) struct ActivatePaneParams { pub(crate) pane_id: PaneIdParam, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MovePaneParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) target_pane_id: PaneIdParam, + pub(crate) axis: SplitAxisPayload, + pub(crate) new_pane_first: Option, +} + #[derive(Clone, Copy, Debug, Deserialize)] pub(crate) struct WorkspaceIdParam(u64); diff --git a/server/src/ipc/messages/tab.rs b/server/src/ipc/messages/tab.rs index f342f50c..438c0ea7 100644 --- a/server/src/ipc/messages/tab.rs +++ b/server/src/ipc/messages/tab.rs @@ -1,7 +1,7 @@ use core::tree::{Tab, TabId, TabKind}; use serde::{Deserialize, Serialize}; -use super::pane::{PaneIdParam, WorkspaceIdParam}; +use super::pane::{PaneIdParam, SplitAxisPayload, WorkspaceIdParam}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -37,6 +37,17 @@ pub(crate) struct ReorderTabParams { pub(crate) target_index: usize, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SplitTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) target_pane_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) axis: SplitAxisPayload, + pub(crate) new_pane_first: Option, +} + #[derive(Clone, Copy, Debug, Deserialize)] pub(crate) struct TabIdParam(u64); diff --git a/server/src/ipc/router/pane.rs b/server/src/ipc/router/pane.rs index 61999d14..7ddbe763 100644 --- a/server/src/ipc/router/pane.rs +++ b/server/src/ipc/router/pane.rs @@ -1,11 +1,12 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; -use super::super::messages::pane::{ActivatePaneParams, SplitPaneParams}; +use super::super::messages::pane::{ActivatePaneParams, MovePaneParams, SplitPaneParams}; use super::{command_response, parse_params, unsupported_action}; pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match request.action.as_str() { "split" => split_pane(state, request), "activate" => activate_pane(state, request), + "move" => move_pane(state, request), _ => unsupported_action(request), } } @@ -28,6 +29,25 @@ fn split_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMessa } } +fn move_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.move_pane( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.target_pane_id.into(), + params.axis.into(), + params.new_pane_first.unwrap_or(false), + ), + state, + "pane.move_failed", + "pane could not be moved", + ), + Err(response) => response, + } +} + fn activate_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match parse_params::(request) { Ok(params) => command_response( diff --git a/server/src/ipc/router/tab.rs b/server/src/ipc/router/tab.rs index ba46f1a0..b7d21942 100644 --- a/server/src/ipc/router/tab.rs +++ b/server/src/ipc/router/tab.rs @@ -1,6 +1,6 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; use super::super::messages::tab::{ - ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, + ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, SplitTabParams, }; use super::{command_response, parse_params, unsupported_action}; @@ -10,6 +10,7 @@ pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve "activate" => activate_tab(state, request), "close" => close_tab(state, request), "reorder" => reorder_tab(state, request), + "split" => split_tab(state, request), _ => unsupported_action(request), } } @@ -85,3 +86,26 @@ fn reorder_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMess Err(response) => response, } } + +fn split_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.split_tab( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params + .target_pane_id + .map(Into::into) + .unwrap_or_else(|| params.pane_id.into()), + params.tab_id.into(), + params.axis.into(), + params.new_pane_first.unwrap_or(false), + ), + state, + "tab.split_failed", + "tab could not be split into a new pane", + ), + Err(response) => response, + } +} From 05a10e333e00f42f3effca4b87dcb68571ab9ce3 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 00:04:52 -0300 Subject: [PATCH 08/98] fix: preserve pane focus and protect ipc socket --- core/src/tree/workspaces.rs | 73 +++++++++++++++++--- server/src/ipc/transport/server.rs | 106 ++++++++++++++++++++++++++--- 2 files changed, 161 insertions(+), 18 deletions(-) diff --git a/core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs index 00b5f1dc..87dc10d6 100644 --- a/core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -146,12 +146,16 @@ impl Workspace { tab_id: TabId, target_index: usize, ) -> bool { - if let Some(pane) = self.root.find_pane_mut(pane_id) { + let reordered = self + .root + .find_pane_mut(pane_id) + .is_some_and(|pane| pane.reorder_tab(tab_id, target_index)); + + if reordered { self.active_pane = pane_id; - pane.reorder_tab(tab_id, target_index) - } else { - false } + + reordered } pub fn activate_tab_in_active_pane(&mut self, tab_id: TabId) -> bool { @@ -160,12 +164,16 @@ impl Workspace { } pub fn activate_tab(&mut self, pane_id: PaneId, tab_id: TabId) -> bool { - if let Some(pane) = self.root.find_pane_mut(pane_id) { + let activated = self + .root + .find_pane_mut(pane_id) + .is_some_and(|pane| pane.activate_tab(tab_id)); + + if activated { self.active_pane = pane_id; - pane.activate_tab(tab_id) - } else { - false } + + activated } pub fn close_tab( @@ -238,6 +246,7 @@ impl Workspace { } fn remove_empty_pane(&mut self, pane_id: PaneId, fallback_pane: Pane) { + let previous_active_pane = self.active_pane; let fallback_pane_id = fallback_pane.id(); let old_root = std::mem::replace(&mut self.root, PaneNode::leaf(fallback_pane)); let (root, removed_pane) = old_root.remove_pane(pane_id); @@ -245,7 +254,11 @@ impl Workspace { debug_assert!(removed_pane.is_some()); if let Some(root) = root { - self.active_pane = root.first_pane_id(); + self.active_pane = if root.contains_pane(previous_active_pane) { + previous_active_pane + } else { + root.first_pane_id() + }; self.root = root; } else { self.active_pane = fallback_pane_id; @@ -433,6 +446,48 @@ mod tests { assert_eq!(workspace.root().pane_count(), 1); } + #[test] + fn failed_tab_activation_keeps_active_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + workspace.activate_pane(PaneId::new(1)); + + assert!(!workspace.activate_tab(PaneId::new(2), TabId::new(999))); + + assert_eq!(workspace.active_pane_id(), PaneId::new(1)); + } + + #[test] + fn failed_tab_reorder_keeps_active_pane() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + workspace.activate_pane(PaneId::new(1)); + + assert!(!workspace.reorder_tab_in_pane(PaneId::new(2), TabId::new(999), 0,)); + + assert_eq!(workspace.active_pane_id(), PaneId::new(1)); + } + + #[test] + fn closing_inactive_empty_pane_keeps_active_pane_when_it_still_exists() { + let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); + workspace.split_active_pane(SplitAxis::Horizontal, pane(2, 2), 0.5); + workspace.split_pane_with_new_pane_first( + PaneId::new(1), + SplitAxis::Vertical, + pane(3, 3), + 0.5, + false, + ); + workspace.activate_pane(PaneId::new(2)); + + let removed_tab = workspace.close_tab(PaneId::new(3), TabId::new(3), pane(4, 4)); + + assert_eq!(removed_tab.map(|tab| tab.id()), Some(TabId::new(3))); + assert!(!workspace.root().contains_pane(PaneId::new(3))); + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + } + #[test] fn moving_pane_splits_target_with_existing_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); diff --git a/server/src/ipc/transport/server.rs b/server/src/ipc/transport/server.rs index d427bb16..d3360337 100644 --- a/server/src/ipc/transport/server.rs +++ b/server/src/ipc/transport/server.rs @@ -1,20 +1,15 @@ use std::fs; use std::io; -use std::os::unix::net::UnixListener; -use std::path::PathBuf; +use std::os::unix::fs::FileTypeExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::thread; use super::connection; pub(crate) fn run(socket_path: PathBuf, state: core::State) -> io::Result<()> { - if let Some(parent) = socket_path.parent() { - fs::create_dir_all(parent)?; - } - - if socket_path.exists() { - fs::remove_file(&socket_path)?; - } + prepare_socket_path(&socket_path)?; let listener = UnixListener::bind(&socket_path)?; let state = Arc::new(Mutex::new(state)); @@ -38,3 +33,96 @@ pub(crate) fn run(socket_path: PathBuf, state: core::State) -> io::Result<()> { Ok(()) } + +fn prepare_socket_path(socket_path: &Path) -> io::Result<()> { + if let Some(parent) = socket_path.parent() { + fs::create_dir_all(parent)?; + } + + let metadata = match fs::symlink_metadata(socket_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + + if !metadata.file_type().is_socket() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "IPC socket path {} already exists and is not a socket", + socket_path.display() + ), + )); + } + + match UnixStream::connect(socket_path) { + Ok(_) => Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "kosmos server is already listening on {}", + socket_path.display() + ), + )), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + fs::remove_file(socket_path) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io::Error::new( + error.kind(), + format!( + "could not inspect existing IPC socket {}: {error}", + socket_path.display() + ), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn socket_path(name: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("kosmos-server-{}-{name}.sock", std::process::id())); + + let _ = fs::remove_file(&path); + path + } + + #[test] + fn prepare_socket_path_removes_stale_socket() { + let path = socket_path("stale"); + let listener = UnixListener::bind(&path).expect("test socket should bind"); + drop(listener); + + prepare_socket_path(&path).expect("stale socket should be removed"); + + assert!(!path.exists()); + } + + #[test] + fn prepare_socket_path_rejects_live_socket() { + let path = socket_path("live"); + let listener = UnixListener::bind(&path).expect("test socket should bind"); + + let error = prepare_socket_path(&path).expect_err("live socket should be rejected"); + + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + + drop(listener); + let _ = fs::remove_file(path); + } + + #[test] + fn prepare_socket_path_does_not_remove_non_socket_file() { + let path = socket_path("file"); + fs::write(&path, b"not a socket").expect("test file should be written"); + + let error = prepare_socket_path(&path).expect_err("plain file should be rejected"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(path.exists()); + + let _ = fs::remove_file(path); + } +} From 0876fd5c4a6e493ea11e773400591197ceee6190 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 00:21:50 -0300 Subject: [PATCH 09/98] refactor: split desktop ipc and ui modules --- desktop/meson.build | 7 + desktop/src/ipc/kosmos_ipc_client.c | 305 --- desktop/src/ipc/kosmos_ipc_pane.c | 89 + desktop/src/ipc/kosmos_ipc_tab.c | 151 ++ desktop/src/ipc/kosmos_ipc_workspace.c | 68 + desktop/src/ui/kosmos_main_window.c | 2061 +------------------ desktop/src/ui/kosmos_main_window_private.h | 104 + desktop/src/ui/kosmos_pane_dnd.c | 702 +++++++ desktop/src/ui/kosmos_pane_layout.c | 416 ++++ desktop/src/ui/kosmos_tabbed_pane.c | 551 +++++ desktop/src/ui/kosmos_workspace_switcher.c | 334 +++ 11 files changed, 2457 insertions(+), 2331 deletions(-) create mode 100644 desktop/src/ipc/kosmos_ipc_pane.c create mode 100644 desktop/src/ipc/kosmos_ipc_tab.c create mode 100644 desktop/src/ipc/kosmos_ipc_workspace.c create mode 100644 desktop/src/ui/kosmos_main_window_private.h create mode 100644 desktop/src/ui/kosmos_pane_dnd.c create mode 100644 desktop/src/ui/kosmos_pane_layout.c create mode 100644 desktop/src/ui/kosmos_tabbed_pane.c create mode 100644 desktop/src/ui/kosmos_workspace_switcher.c diff --git a/desktop/meson.build b/desktop/meson.build index ab7444dd..5df23e3c 100644 --- a/desktop/meson.build +++ b/desktop/meson.build @@ -18,8 +18,15 @@ sources = files( 'src/main.c', 'src/app/kosmos_application.c', 'src/ipc/kosmos_ipc_client.c', + 'src/ipc/kosmos_ipc_pane.c', 'src/ipc/kosmos_ipc_protocol.c', + 'src/ipc/kosmos_ipc_tab.c', + 'src/ipc/kosmos_ipc_workspace.c', 'src/ui/kosmos_main_window.c', + 'src/ui/kosmos_pane_dnd.c', + 'src/ui/kosmos_pane_layout.c', + 'src/ui/kosmos_tabbed_pane.c', + 'src/ui/kosmos_workspace_switcher.c', ) executable( diff --git a/desktop/src/ipc/kosmos_ipc_client.c b/desktop/src/ipc/kosmos_ipc_client.c index 6e9533bc..bb23ab96 100644 --- a/desktop/src/ipc/kosmos_ipc_client.c +++ b/desktop/src/ipc/kosmos_ipc_client.c @@ -395,308 +395,3 @@ gboolean kosmos_ipc_client_request( return parsed; } - -gboolean kosmos_ipc_client_list_workspaces( - KosmosIpcClient *self, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - return kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_LIST, - NULL, - result, - cancellable, - error - ); -} - -gboolean kosmos_ipc_client_open_workspace( - KosmosIpcClient *self, - const char *path, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_string_member(params, "path", path); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_OPEN, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_activate_workspace( - KosmosIpcClient *self, - guint64 workspace_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_activate_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_split_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_SPLIT, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_move_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_MOVE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_open_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - const char *title, - KosmosIpcTabKind kind, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_string_member(params, "title", title == NULL ? "Blank" : title); - json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_OPEN, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_activate_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_close_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_CLOSE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_reorder_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - guint target_index, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - json_object_set_int_member(params, "targetIndex", (gint64)target_index); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_REORDER, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_split_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - guint64 tab_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_SPLIT, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} diff --git a/desktop/src/ipc/kosmos_ipc_pane.c b/desktop/src/ipc/kosmos_ipc_pane.c new file mode 100644 index 00000000..81a1b4f3 --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_pane.c @@ -0,0 +1,89 @@ +#include "ipc/kosmos_ipc_client.h" + +gboolean kosmos_ipc_client_activate_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_split_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_SPLIT, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_move_pane( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_MOVE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_tab.c b/desktop/src/ipc/kosmos_ipc_tab.c new file mode 100644 index 00000000..1b10cca8 --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_tab.c @@ -0,0 +1,151 @@ +#include "ipc/kosmos_ipc_client.h" + +gboolean kosmos_ipc_client_open_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + const char *title, + KosmosIpcTabKind kind, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_string_member(params, "title", title == NULL ? "Blank" : title); + json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_OPEN, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_activate_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_close_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_CLOSE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_reorder_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + guint target_index, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + json_object_set_int_member(params, "targetIndex", (gint64)target_index); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_REORDER, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_split_tab( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + guint64 tab_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); + json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_SPLIT, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_workspace.c b/desktop/src/ipc/kosmos_ipc_workspace.c new file mode 100644 index 00000000..6f11addb --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_workspace.c @@ -0,0 +1,68 @@ +#include "ipc/kosmos_ipc_client.h" + +gboolean kosmos_ipc_client_list_workspaces( + KosmosIpcClient *self, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + return kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_LIST, + NULL, + result, + cancellable, + error + ); +} + +gboolean kosmos_ipc_client_open_workspace( + KosmosIpcClient *self, + const char *path, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_string_member(params, "path", path); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_OPEN, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_activate_workspace( + KosmosIpcClient *self, + guint64 workspace_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_WORKSPACE, + KOSMOS_IPC_ACTION_ACTIVATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ui/kosmos_main_window.c b/desktop/src/ui/kosmos_main_window.c index 4747b3a6..56086756 100644 --- a/desktop/src/ui/kosmos_main_window.c +++ b/desktop/src/ui/kosmos_main_window.c @@ -1,82 +1,8 @@ -#include "ui/kosmos_main_window.h" - -#include - -struct _KosmosMainWindow { - GtkApplicationWindow parent_instance; - KosmosIpcClient *ipc_client; - GtkWidget *workspace_switcher; - GtkWidget *add_workspace_button; - GtkWidget *content_overlay; - GtkWidget *content_area; - GHashTable *workspace_buttons; - GHashTable *paned_ratios; - GHashTable *pane_views; - char *layout_signature; - AdwTabView *detached_tab_sink; - GtkWindow *detached_tab_window; - gboolean splitting_detached_tab; - gboolean suppress_tab_detach; - gboolean applying_server_state; -}; - -typedef struct { - KosmosMainWindow *window; - guint64 workspace_id; - guint64 pane_id; - guint64 target_pane_id; - guint64 tab_id; - KosmosIpcSplitAxis axis; - gboolean new_pane_first; -} DetachedTabSplit; - -typedef struct { - guint64 workspace_id; - guint64 pane_id; - guint64 tab_id; -} PendingTabActivation; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; -} TabBarActivation; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; -} DeferredTabActivation; - -typedef struct { - guint64 target_pane_id; - KosmosIpcSplitAxis axis; - gboolean new_pane_first; -} SplitDropZone; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; - GtkWidget *source; - GtkWidget *excluded_widget; - GtkWidget *preview; - char *title; - guint64 workspace_id; - guint64 pane_id; - double start_x; - double start_y; - gboolean active; -} PaneDrag; - -typedef struct { - GtkOrientation orientation; - double ratio; -} PanedRatio; - -static const int KOSMOS_MIN_PANE_WIDTH = 220; -static const int KOSMOS_MIN_PANE_HEIGHT = 160; +#include "ui/kosmos_main_window_private.h" G_DEFINE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, GTK_TYPE_APPLICATION_WINDOW) -static GtkWidget *create_label(const char *text, const char *css_class) { +GtkWidget *kosmos_main_window_create_label(const char *text, const char *css_class) { GtkWidget *label = gtk_label_new(text); gtk_label_set_wrap(GTK_LABEL(label), TRUE); gtk_label_set_xalign(GTK_LABEL(label), 0.0f); @@ -88,38 +14,7 @@ static GtkWidget *create_label(const char *text, const char *css_class) { return label; } -static void install_split_drop_css(GtkWidget *widget) { - static gboolean installed = FALSE; - - if (installed) { - return; - } - - GtkCssProvider *provider = gtk_css_provider_new(); - gtk_css_provider_load_from_string( - provider, - ".split-drop-highlight {" - " background: rgba(53, 132, 228, 0.24);" - " border: 2px solid rgba(53, 132, 228, 0.72);" - " border-radius: 10px;" - "}" - ".pane-drag-preview {" - " background: rgba(98, 99, 107, 0.96);" - " border-radius: 9px;" - " padding: 8px 12px;" - " box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);" - "}" - ); - gtk_style_context_add_provider_for_display( - gtk_widget_get_display(widget), - GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION - ); - g_object_unref(provider); - installed = TRUE; -} - -static void clear_content_area(KosmosMainWindow *self) { +void kosmos_main_window_clear_content_area(KosmosMainWindow *self) { self->suppress_tab_detach = TRUE; if (self->pane_views != NULL) { @@ -138,13 +33,13 @@ static void clear_content_area(KosmosMainWindow *self) { self->suppress_tab_detach = FALSE; } -static void set_uint64_data(GObject *object, const char *key, guint64 value) { +void kosmos_main_window_set_uint64_data(GObject *object, const char *key, guint64 value) { guint64 *stored_value = g_new(guint64, 1); *stored_value = value; g_object_set_data_full(object, key, stored_value, g_free); } -static gboolean get_uint64_data(GObject *object, const char *key, guint64 *value) { +gboolean kosmos_main_window_get_uint64_data(GObject *object, const char *key, guint64 *value) { guint64 *stored_value = g_object_get_data(object, key); if (stored_value == NULL) { @@ -155,73 +50,17 @@ static gboolean get_uint64_data(GObject *object, const char *key, guint64 *value return TRUE; } -static void register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view) { +void kosmos_main_window_register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view) { guint64 *key = g_new(guint64, 1); *key = pane_id; g_hash_table_replace(self->pane_views, key, view); } -static AdwTabView *pane_view_for(KosmosMainWindow *self, guint64 pane_id) { +AdwTabView *kosmos_main_window_pane_view_for(KosmosMainWindow *self, guint64 pane_id) { return g_hash_table_lookup(self->pane_views, &pane_id); } -static void set_paned_ratio(KosmosMainWindow *self, const char *path, double ratio) { - double *stored_ratio = g_new(double, 1); - *stored_ratio = CLAMP(ratio, 0.05, 0.95); - g_hash_table_replace(self->paned_ratios, g_strdup(path), stored_ratio); -} - -static gboolean get_paned_ratio(KosmosMainWindow *self, const char *path, double *ratio) { - double *stored_ratio = g_hash_table_lookup(self->paned_ratios, path); - - if (stored_ratio == NULL) { - return FALSE; - } - - *ratio = *stored_ratio; - return TRUE; -} - -static void remember_paned_ratios_from_widget(KosmosMainWindow *self, GtkWidget *widget) { - if (GTK_IS_PANED(widget)) { - const char *path = g_object_get_data(G_OBJECT(widget), "pane-path"); - GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); - int size = orientation == GTK_ORIENTATION_HORIZONTAL - ? gtk_widget_get_width(widget) - : gtk_widget_get_height(widget); - - if (path != NULL && size > 1) { - set_paned_ratio(self, path, (double)gtk_paned_get_position(GTK_PANED(widget)) / size); - } - } - - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - remember_paned_ratios_from_widget(self, child); - } -} - -static void remember_paned_ratios(KosmosMainWindow *self) { - if (self->content_area == NULL) { - return; - } - - remember_paned_ratios_from_widget(self, self->content_area); -} - -static void clear_workspace_switcher(KosmosMainWindow *self) { - GtkWidget *child = gtk_widget_get_first_child(self->workspace_switcher); - - while (child != NULL) { - GtkWidget *next = gtk_widget_get_next_sibling(child); - gtk_box_remove(GTK_BOX(self->workspace_switcher), child); - child = next; - } - - g_hash_table_remove_all(self->workspace_buttons); - self->add_workspace_button = NULL; -} - -static gboolean get_uint_member(JsonObject *object, const char *name, guint64 *value) { +gboolean kosmos_json_get_uint_member(JsonObject *object, const char *name, guint64 *value) { JsonNode *node = json_object_get_member(object, name); if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { return FALSE; @@ -236,7 +75,7 @@ static gboolean get_uint_member(JsonObject *object, const char *name, guint64 *v return TRUE; } -static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { +gboolean kosmos_json_get_string_member(JsonObject *object, const char *name, const char **value) { JsonNode *node = json_object_get_member(object, name); if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { return FALSE; @@ -246,7 +85,7 @@ static gboolean get_string_member(JsonObject *object, const char *name, const ch return TRUE; } -static gboolean get_double_member(JsonObject *object, const char *name, double *value) { +gboolean kosmos_json_get_double_member(JsonObject *object, const char *name, double *value) { JsonNode *node = json_object_get_member(object, name); if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { return FALSE; @@ -256,7 +95,7 @@ static gboolean get_double_member(JsonObject *object, const char *name, double * return TRUE; } -static JsonObject *get_object_member(JsonObject *object, const char *name) { +JsonObject *kosmos_json_get_object_member(JsonObject *object, const char *name) { JsonNode *node = json_object_get_member(object, name); if (node == NULL || !JSON_NODE_HOLDS_OBJECT(node)) { @@ -266,7 +105,7 @@ static JsonObject *get_object_member(JsonObject *object, const char *name) { return json_node_get_object(node); } -static JsonArray *get_array_member(JsonObject *object, const char *name) { +JsonArray *kosmos_json_get_array_member(JsonObject *object, const char *name) { JsonNode *node = json_object_get_member(object, name); if (node == NULL || !JSON_NODE_HOLDS_ARRAY(node)) { @@ -276,1887 +115,57 @@ static JsonArray *get_array_member(JsonObject *object, const char *name) { return json_node_get_array(node); } -static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_workspace_id) { - JsonNode *node = json_object_get_member(result, "activeWorkspaceId"); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { - return FALSE; - } - - gint64 signed_value = json_node_get_int(node); - if (signed_value < 0) { - return FALSE; - } - - *active_workspace_id = (guint64)signed_value; - return TRUE; -} - -static void set_status(KosmosMainWindow *self, const char *status) { - clear_content_area(self); +void kosmos_main_window_set_status(KosmosMainWindow *self, const char *status) { + kosmos_main_window_clear_content_area(self); if (status == NULL || status[0] == '\0') { return; } - GtkWidget *label = create_label(status, "dim-label"); + GtkWidget *label = kosmos_main_window_create_label(status, "dim-label"); gtk_widget_set_halign(label, GTK_ALIGN_CENTER); gtk_widget_set_valign(label, GTK_ALIGN_CENTER); gtk_widget_set_vexpand(label, TRUE); gtk_box_append(GTK_BOX(self->content_area), label); } -static void set_error_status(KosmosMainWindow *self, const char *prefix, GError *error) { +void kosmos_main_window_set_error_status(KosmosMainWindow *self, const char *prefix, GError *error) { char *message = g_strdup_printf("%s: %s", prefix, error->message); - set_status(self, message); + kosmos_main_window_set_status(self, message); g_free(message); } -static gboolean ensure_connected(KosmosMainWindow *self) { +gboolean kosmos_main_window_ensure_connected(KosmosMainWindow *self) { GError *error = NULL; if (kosmos_ipc_client_connect(self->ipc_client, NULL, &error)) { return TRUE; } - set_status(self, "Start kosmos-server with ./scripts/run.sh to load workspaces."); - clear_workspace_switcher(self); + kosmos_main_window_set_status(self, "Start kosmos-server with ./scripts/run.sh to load workspaces."); + kosmos_workspace_switcher_clear(self); g_clear_error(&error); return FALSE; } -static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state); - -static void activate_workspace(GtkWidget *button, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - guint64 *workspace_id = g_object_get_data(G_OBJECT(button), "workspace-id"); - - if (workspace_id == NULL || !ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - if (!kosmos_ipc_client_activate_workspace(self->ipc_client, *workspace_id, &state, NULL, &error)) { - set_error_status(self, "Failed to activate workspace", error); - g_clear_error(&error); - return; - } - - apply_workspace_state(self, state); - json_node_unref(state); -} - -static void open_workspace_path(KosmosMainWindow *self, const char *path) { - if (!ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - if (!kosmos_ipc_client_open_workspace(self->ipc_client, path, &state, NULL, &error)) { - set_error_status(self, "Failed to open workspace", error); - g_clear_error(&error); - return; - } - - apply_workspace_state(self, state); - json_node_unref(state); -} - -static void open_workspace_selected(GObject *source_object, GAsyncResult *result, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - GError *error = NULL; - GFile *folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source_object), result, &error); - - if (folder == NULL) { - if (!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { - set_error_status(self, "Failed to select workspace", error); - } - - g_clear_error(&error); - g_object_unref(self); - return; - } - - char *path = g_file_get_path(folder); - if (path == NULL) { - set_status(self, "Selected workspace is not a local directory."); - } else { - open_workspace_path(self, path); - } - - g_free(path); - g_object_unref(folder); - g_object_unref(self); -} - -static void open_workspace_picker(GtkButton *button, gpointer user_data) { - (void)button; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - GtkFileDialog *dialog = gtk_file_dialog_new(); - gtk_file_dialog_set_title(dialog, "Open Workspace"); - gtk_file_dialog_select_folder(dialog, GTK_WINDOW(self), NULL, open_workspace_selected, g_object_ref(self)); - g_object_unref(dialog); -} - -static GtkWidget *ensure_add_workspace_button(KosmosMainWindow *self, gboolean sensitive) { - if (self->add_workspace_button == NULL) { - self->add_workspace_button = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_set_tooltip_text(self->add_workspace_button, "Open workspace"); - gtk_box_append(GTK_BOX(self->workspace_switcher), self->add_workspace_button); - g_signal_connect(self->add_workspace_button, "clicked", G_CALLBACK(open_workspace_picker), self); - } - - gtk_widget_set_sensitive(self->add_workspace_button, sensitive); - return self->add_workspace_button; -} - -static void split_workspace_name(const char *name, char **initial, char **suffix) { - if (name == NULL || name[0] == '\0') { - *initial = g_strdup("?"); - *suffix = g_strdup(""); - return; - } - - const char *suffix_start = g_utf8_next_char(name); - *initial = g_strndup(name, suffix_start - name); - *suffix = g_strdup(suffix_start); -} - -static void update_workspace_button_name(GtkWidget *button, const char *name) { - GtkWidget *initial_label = g_object_get_data(G_OBJECT(button), "workspace-initial-label"); - GtkWidget *suffix_label = g_object_get_data(G_OBJECT(button), "workspace-suffix-label"); - char *initial = NULL; - char *suffix = NULL; - - split_workspace_name(name, &initial, &suffix); - gtk_label_set_text(GTK_LABEL(initial_label), initial); - gtk_label_set_text(GTK_LABEL(suffix_label), suffix); - - g_free(initial); - g_free(suffix); -} - -static void set_workspace_button_active(GtkWidget *button, gboolean active) { - GtkWidget *revealer = g_object_get_data(G_OBJECT(button), "workspace-suffix-revealer"); - - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); - gtk_revealer_set_reveal_child(GTK_REVEALER(revealer), active); -} - -static GtkWidget *create_workspace_button(guint64 workspace_id, const char *name) { - GtkWidget *button = gtk_toggle_button_new(); - GtkWidget *label_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - GtkWidget *initial_label = gtk_label_new(NULL); - GtkWidget *suffix_label = gtk_label_new(NULL); - GtkWidget *revealer = gtk_revealer_new(); - - gtk_widget_set_halign(label_box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label_box, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(initial_label), 0.5f); - - gtk_revealer_set_transition_type(GTK_REVEALER(revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT); - gtk_revealer_set_transition_duration(GTK_REVEALER(revealer), 180); - gtk_revealer_set_child(GTK_REVEALER(revealer), suffix_label); - - gtk_box_append(GTK_BOX(label_box), initial_label); - gtk_box_append(GTK_BOX(label_box), revealer); - gtk_button_set_child(GTK_BUTTON(button), label_box); - - g_object_set_data(G_OBJECT(button), "workspace-initial-label", initial_label); - g_object_set_data(G_OBJECT(button), "workspace-suffix-label", suffix_label); - g_object_set_data(G_OBJECT(button), "workspace-suffix-revealer", revealer); - - guint64 *button_workspace_id = g_new(guint64, 1); - *button_workspace_id = workspace_id; - g_object_set_data_full(G_OBJECT(button), "workspace-id", button_workspace_id, g_free); - - update_workspace_button_name(button, name); - - return button; -} - -static GtkWidget *workspace_button_for(KosmosMainWindow *self, guint64 workspace_id, const char *name) { - GtkWidget *button = g_hash_table_lookup(self->workspace_buttons, &workspace_id); - - if (button != NULL) { - update_workspace_button_name(button, name); - return button; - } - - button = create_workspace_button(workspace_id, name); - guint64 *key = g_new(guint64, 1); - *key = workspace_id; - g_hash_table_insert(self->workspace_buttons, key, button); - gtk_box_append(GTK_BOX(self->workspace_switcher), button); - g_signal_connect(button, "clicked", G_CALLBACK(activate_workspace), self); - - return button; -} - -static void remove_stale_workspace_buttons(KosmosMainWindow *self, GHashTable *seen_workspace_ids) { - GHashTableIter iter; - gpointer key = NULL; - gpointer value = NULL; - - g_hash_table_iter_init(&iter, self->workspace_buttons); - while (g_hash_table_iter_next(&iter, &key, &value)) { - if (!g_hash_table_contains(seen_workspace_ids, key)) { - gtk_box_remove(GTK_BOX(self->workspace_switcher), GTK_WIDGET(value)); - g_hash_table_iter_remove(&iter); - } - } -} - -static GtkWidget *update_workspace_button(KosmosMainWindow *self, JsonObject *workspace, guint64 active_workspace_id, gboolean has_active_workspace) { - guint64 workspace_id = 0; - const char *name = NULL; - - if (!get_uint_member(workspace, "id", &workspace_id) || !get_string_member(workspace, "name", &name)) { - return NULL; - } - - GtkWidget *button = workspace_button_for(self, workspace_id, name); - set_workspace_button_active(button, has_active_workspace && workspace_id == active_workspace_id); - - return button; -} - -static JsonObject *find_active_workspace(JsonArray *workspaces, guint64 active_workspace_id, gboolean has_active_workspace) { - if (!has_active_workspace) { - return NULL; - } - - guint workspace_count = json_array_get_length(workspaces); - for (guint index = 0; index < workspace_count; index++) { - JsonNode *workspace_node = json_array_get_element(workspaces, index); - if (workspace_node == NULL || !JSON_NODE_HOLDS_OBJECT(workspace_node)) { - continue; - } - - JsonObject *workspace = json_node_get_object(workspace_node); - guint64 workspace_id = 0; - if (get_uint_member(workspace, "id", &workspace_id) && workspace_id == active_workspace_id) { - return workspace; - } - } - - return NULL; -} - -static gboolean append_layout_signature(GString *signature, JsonObject *node) { - const char *type = NULL; - if (!get_string_member(node, "type", &type)) { - return FALSE; - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { - return FALSE; - } - - g_string_append_printf(signature, "L:%" G_GUINT64_FORMAT, pane_id); - return TRUE; - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - JsonObject *first = get_object_member(node, "first"); - JsonObject *second = get_object_member(node, "second"); - if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { - return FALSE; - } - - g_string_append_printf(signature, "S:%s(", axis); - if (!append_layout_signature(signature, first)) { - return FALSE; - } - g_string_append_c(signature, ','); - if (!append_layout_signature(signature, second)) { - return FALSE; - } - g_string_append_c(signature, ')'); - return TRUE; - } - - return FALSE; -} - -static char *create_layout_signature(JsonObject *workspace) { - JsonObject *root = get_object_member(workspace, "root"); - if (root == NULL) { - return NULL; - } - - GString *signature = g_string_new(NULL); - if (!append_layout_signature(signature, root)) { - g_string_free(signature, TRUE); - return NULL; - } - - return g_string_free(signature, FALSE); -} - -static void collect_split_paths(JsonObject *node, const char *path, GHashTable *split_paths) { - const char *type = NULL; - if (!get_string_member(node, "type", &type)) { - return; - } - - if (g_strcmp0(type, "split") != 0) { - return; - } - - JsonObject *first = get_object_member(node, "first"); - JsonObject *second = get_object_member(node, "second"); - if (first == NULL || second == NULL) { - return; - } - - g_hash_table_add(split_paths, g_strdup(path)); - - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - collect_split_paths(first, first_path, split_paths); - collect_split_paths(second, second_path, split_paths); - g_free(first_path); - g_free(second_path); -} - -static void prune_paned_ratios_for_root(KosmosMainWindow *self, JsonObject *root) { - GHashTable *split_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); - collect_split_paths(root, "root", split_paths); - - GHashTableIter iter; - gpointer key = NULL; - gpointer value = NULL; - g_hash_table_iter_init(&iter, self->paned_ratios); - while (g_hash_table_iter_next(&iter, &key, &value)) { - if (!g_hash_table_contains(split_paths, key)) { - g_hash_table_iter_remove(&iter); - } - } - - g_hash_table_unref(split_paths); -} - -static void apply_server_state_or_show_error(KosmosMainWindow *self, JsonNode *state, GError *error, const char *error_prefix) { +void kosmos_main_window_apply_server_state_or_show_error( + KosmosMainWindow *self, + JsonNode *state, + GError *error, + const char *error_prefix +) { if (error != NULL) { - set_error_status(self, error_prefix, error); - return; - } - - apply_workspace_state(self, state); -} - -static void clear_pending_tab_activation(AdwTabView *view) { - g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); -} - -static void set_pending_tab_activation(AdwTabView *view, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { - PendingTabActivation *activation = g_new(PendingTabActivation, 1); - activation->workspace_id = workspace_id; - activation->pane_id = pane_id; - activation->tab_id = tab_id; - - g_object_set_data_full(G_OBJECT(view), "pending-tab-activation", activation, g_free); -} - -static void request_tab_activation(KosmosMainWindow *self, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { - if (!ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_activate_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); - apply_server_state_or_show_error(self, state, error, "Failed to activate tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static void tab_bar_pressed(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)gesture; - (void)n_press; - (void)x; - (void)y; - - TabBarActivation *activation = user_data; - g_object_set_data(G_OBJECT(activation->view), "tab-press-active", GINT_TO_POINTER(1)); -} - -static void deferred_tab_activation_free(DeferredTabActivation *activation) { - g_object_unref(activation->window); - g_object_unref(activation->view); - g_free(activation); -} - -static gboolean finish_pending_tab_activation(gpointer user_data) { - DeferredTabActivation *activation = user_data; - AdwTabView *view = activation->view; - PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); - PendingTabActivation pending_copy = {0}; - - if (gtk_widget_get_root(GTK_WIDGET(view)) == NULL || pending == NULL || adw_tab_view_get_is_transferring_page(view)) { - clear_pending_tab_activation(view); - return G_SOURCE_REMOVE; - } - - pending_copy = *pending; - clear_pending_tab_activation(view); - request_tab_activation(activation->window, pending_copy.workspace_id, pending_copy.pane_id, pending_copy.tab_id); - - return G_SOURCE_REMOVE; -} - -static void tab_bar_released(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)gesture; - (void)n_press; - (void)x; - (void)y; - - TabBarActivation *activation = user_data; - AdwTabView *view = activation->view; - PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); - - g_object_set_data(G_OBJECT(view), "tab-press-active", NULL); - if (pending == NULL || adw_tab_view_get_is_transferring_page(view)) { - clear_pending_tab_activation(view); - return; - } - - DeferredTabActivation *deferred = g_new(DeferredTabActivation, 1); - deferred->window = g_object_ref(activation->window); - deferred->view = g_object_ref(view); - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_pending_tab_activation, deferred, (GDestroyNotify)deferred_tab_activation_free); -} - -static void tab_transfer_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { - (void)pspec; - (void)user_data; - - AdwTabView *view = ADW_TAB_VIEW(object); - if (adw_tab_view_get_is_transferring_page(view)) { - clear_pending_tab_activation(view); - } -} - -static void tab_detached(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - (void)page; - (void)position; - (void)user_data; - - clear_pending_tab_activation(view); -} - -static void activate_selected_tab(GObject *object, GParamSpec *pspec, gpointer user_data) { - (void)pspec; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - if (self->applying_server_state) { - return; - } - - AdwTabView *view = ADW_TAB_VIEW(object); - if (adw_tab_view_get_is_transferring_page(view)) { - return; - } - - AdwTabPage *page = adw_tab_view_get_selected_page(view); - - if (page == NULL || !ensure_connected(self)) { - return; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { - return; - } - - if (g_object_get_data(G_OBJECT(view), "tab-press-active") != NULL) { - set_pending_tab_activation(view, workspace_id, pane_id, tab_id); + kosmos_main_window_set_error_status(self, error_prefix, error); return; } - request_tab_activation(self, workspace_id, pane_id, tab_id); -} - -static gboolean close_tab(AdwTabView *view, AdwTabPage *page, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - - if (self->applying_server_state) { - adw_tab_view_close_page_finish(view, page, TRUE); - return GDK_EVENT_STOP; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || - !ensure_connected(self)) { - adw_tab_view_close_page_finish(view, page, FALSE); - return GDK_EVENT_STOP; - } - - GError *error = NULL; - JsonNode *state = NULL; - gboolean closed = kosmos_ipc_client_close_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); - adw_tab_view_close_page_finish(view, page, closed); - apply_server_state_or_show_error(self, state, error, "Failed to close tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } - - return GDK_EVENT_STOP; -} - -static void reorder_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - clear_pending_tab_activation(view); - - if (self->applying_server_state) { - return; - } - - if (adw_tab_view_get_is_transferring_page(view)) { - return; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || - !ensure_connected(self)) { - kosmos_main_window_refresh_workspace_state(self); - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_reorder_tab(self->ipc_client, workspace_id, pane_id, tab_id, (guint)position, &state, NULL, &error); - - if (error != NULL) { - g_clear_error(&error); - kosmos_main_window_refresh_workspace_state(self); - return; - } - - apply_workspace_state(self, state); - if (state != NULL) { - json_node_unref(state); - } -} - -static void detached_tab_split_free(DetachedTabSplit *split) { - g_object_unref(split->window); - g_free(split); -} - -static void clear_detached_tab_transfer(KosmosMainWindow *self) { - if (self->detached_tab_window != NULL) { - gtk_window_destroy(self->detached_tab_window); - } - - g_clear_object(&self->detached_tab_window); - g_clear_object(&self->detached_tab_sink); -} - -static void request_split_tab( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - guint64 tab_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - if (!ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_split_tab( - self->ipc_client, - workspace_id, - pane_id, - target_pane_id, - tab_id, - axis, - new_pane_first, - &state, - NULL, - &error - ); - apply_server_state_or_show_error(self, state, error, "Failed to split tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static void request_move_pane( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - if (!ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_move_pane( - self->ipc_client, - workspace_id, - pane_id, - target_pane_id, - axis, - new_pane_first, - &state, - NULL, - &error - ); - apply_server_state_or_show_error(self, state, error, "Failed to move pane"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static gboolean finish_detached_tab_split(gpointer user_data) { - DetachedTabSplit *split = user_data; - KosmosMainWindow *self = split->window; - - request_split_tab( - self, - split->workspace_id, - split->pane_id, - split->target_pane_id, - split->tab_id, - split->axis, - split->new_pane_first - ); - - clear_detached_tab_transfer(self); - return G_SOURCE_REMOVE; -} - -static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - (void)position; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - gboolean is_split_target = g_object_get_data(G_OBJECT(view), "split-drop-target") != NULL; - - if (!self->splitting_detached_tab && !is_split_target) { - return; - } - - self->splitting_detached_tab = FALSE; - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { - kosmos_main_window_refresh_workspace_state(self); - return; - } - - guint64 target_pane_id = pane_id; - get_uint64_data(G_OBJECT(view), "target-pane-id", &target_pane_id); - - gpointer axis_data = g_object_get_data(G_OBJECT(view), "split-axis"); - KosmosIpcSplitAxis axis = axis_data == NULL - ? KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL - : (KosmosIpcSplitAxis)(GPOINTER_TO_INT(axis_data) - 1); - - gpointer new_pane_first_data = g_object_get_data(G_OBJECT(view), "split-new-pane-first"); - gboolean new_pane_first = new_pane_first_data == NULL - ? FALSE - : GPOINTER_TO_INT(new_pane_first_data) - 1; - - DetachedTabSplit *split = g_new(DetachedTabSplit, 1); - split->window = g_object_ref(self); - split->workspace_id = workspace_id; - split->pane_id = pane_id; - split->target_pane_id = target_pane_id; - split->tab_id = tab_id; - split->axis = axis; - split->new_pane_first = new_pane_first; - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); -} - -static AdwTabView *create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - clear_pending_tab_activation(view); - clear_detached_tab_transfer(self); - - GtkWidget *window = adw_window_new(); - AdwTabView *sink = adw_tab_view_new(); - - self->splitting_detached_tab = TRUE; - self->detached_tab_window = GTK_WINDOW(g_object_ref_sink(window)); - self->detached_tab_sink = ADW_TAB_VIEW(g_object_ref_sink(sink)); - - g_object_set_data(G_OBJECT(sink), "split-drop-target", GINT_TO_POINTER(1)); - gtk_window_set_child(self->detached_tab_window, GTK_WIDGET(sink)); - gtk_window_set_transient_for(self->detached_tab_window, GTK_WINDOW(self)); - g_signal_connect(sink, "page-attached", G_CALLBACK(split_attached_tab), self); - gtk_window_present(self->detached_tab_window); - - return sink; -} - -static void open_blank_tab(GtkButton *button, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - - guint64 workspace_id = 0; - guint64 pane_id = 0; - if (!get_uint64_data(G_OBJECT(button), "workspace-id", &workspace_id) || - !get_uint64_data(G_OBJECT(button), "pane-id", &pane_id) || - !ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_open_tab( - self->ipc_client, - workspace_id, - pane_id, - "Blank", - KOSMOS_IPC_TAB_KIND_BLANK, - &state, - NULL, - &error - ); - apply_server_state_or_show_error(self, state, error, "Failed to open tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static GtkWidget *create_new_tab_button(guint64 workspace_id, guint64 pane_id) { - GtkWidget *button = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_set_tooltip_text(button, "Open blank tab"); - set_uint64_data(G_OBJECT(button), "workspace-id", workspace_id); - set_uint64_data(G_OBJECT(button), "pane-id", pane_id); - - return button; -} - -static GtkWidget *create_tab_content(const char *kind) { - (void)kind; - - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_hexpand(content, TRUE); - gtk_widget_set_vexpand(content, TRUE); - return content; -} - -static void configure_tab_page(AdwTabPage *page, guint64 workspace_id, guint64 pane_id, guint64 tab_id, const char *title) { - adw_tab_page_set_title(page, title); - adw_tab_page_set_tooltip(page, title); - set_uint64_data(G_OBJECT(page), "workspace-id", workspace_id); - set_uint64_data(G_OBJECT(page), "pane-id", pane_id); - set_uint64_data(G_OBJECT(page), "tab-id", tab_id); -} - -static void add_tab_bar_activation_controller(KosmosMainWindow *self, GtkWidget *tab_bar, AdwTabView *tab_view) { - GtkGesture *gesture = gtk_gesture_click_new(); - TabBarActivation *activation = g_new(TabBarActivation, 1); - - activation->window = self; - activation->view = tab_view; - - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); - g_object_set_data_full(G_OBJECT(gesture), "tab-bar-activation", activation, g_free); - g_signal_connect(gesture, "pressed", G_CALLBACK(tab_bar_pressed), activation); - g_signal_connect(gesture, "released", G_CALLBACK(tab_bar_released), activation); - gtk_widget_add_controller(tab_bar, GTK_EVENT_CONTROLLER(gesture)); -} - -static void set_split_drop_highlight(GtkDropControllerMotion *controller, gboolean highlighted) { - GtkWidget *highlight = g_object_get_data(G_OBJECT(controller), "split-drop-highlight"); - - if (highlight != NULL) { - gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); - } -} - -static void set_split_zone_highlight(GtkWidget *zone, gboolean highlighted) { - GtkWidget *highlight = g_object_get_data(G_OBJECT(zone), "split-drop-highlight"); - - if (highlight != NULL) { - gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); - } -} - -static void set_pane_drag_highlight(KosmosMainWindow *self, GtkWidget *zone) { - GtkWidget *current_zone = g_object_get_data(G_OBJECT(self), "pane-drag-highlight-zone"); - - if (current_zone == zone) { - return; - } - - if (current_zone != NULL) { - set_split_zone_highlight(current_zone, FALSE); - } - - if (zone != NULL) { - set_split_zone_highlight(zone, TRUE); - } - - g_object_set_data(G_OBJECT(self), "pane-drag-highlight-zone", zone); -} - -static gboolean split_zone_contains_point(GtkWidget *zone, GtkWidget *source, double source_x, double source_y) { - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)source_x, (float)source_y); - graphene_point_t zone_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - - if (!gtk_widget_compute_point(source, zone, &source_point, &zone_point)) { - return FALSE; - } - - return zone_point.x >= 0.0f && - zone_point.y >= 0.0f && - zone_point.x < gtk_widget_get_width(zone) && - zone_point.y < gtk_widget_get_height(zone); -} - -static gboolean find_split_zone_at( - GtkWidget *widget, - GtkWidget *source, - double source_x, - double source_y, - guint64 source_pane_id, - GtkWidget **zone_widget, - SplitDropZone *zone -) { - if (!gtk_widget_get_visible(widget)) { - return FALSE; - } - - SplitDropZone *candidate = g_object_get_data(G_OBJECT(widget), "split-drop-zone"); - if (candidate != NULL && - candidate->target_pane_id != source_pane_id && - split_zone_contains_point(widget, source, source_x, source_y)) { - *zone_widget = widget; - *zone = *candidate; - return TRUE; - } - - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - if (find_split_zone_at(child, source, source_x, source_y, source_pane_id, zone_widget, zone)) { - return TRUE; - } - } - - return FALSE; -} - -static void show_pane_drag_preview(PaneDrag *drag); -static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y); - -static void pane_drag_begin(GtkGestureDrag *gesture, double start_x, double start_y, gpointer user_data) { - PaneDrag *drag = user_data; - - drag->active = FALSE; - if (!pane_drag_start_is_valid(drag, start_x, start_y)) { - gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED); - return; - } - - drag->start_x = start_x; - drag->start_y = start_y; - set_pane_drag_highlight(drag->window, NULL); -} - -static gboolean pane_drag_find_zone(PaneDrag *drag, double x, double y, GtkWidget **zone_widget, SplitDropZone *zone) { - return find_split_zone_at( - drag->window->content_area, - drag->source, - x, - y, - drag->pane_id, - zone_widget, - zone - ); -} - -static gboolean picked_widget_is_or_contains(GtkWidget *picked, GtkWidget *widget) { - if (widget == NULL || picked == NULL) { - return FALSE; - } - - return picked == widget || (picked != NULL && gtk_widget_is_ancestor(picked, widget)); -} - -static gboolean pane_drag_starts_on_action_area(PaneDrag *drag, double x, double y) { - if (drag->excluded_widget == NULL) { - return FALSE; - } - - graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); - graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point) && x >= source_point.x - 8.0) { - return TRUE; - } - - return split_zone_contains_point(drag->excluded_widget, drag->source, x, y); -} - -static gboolean pane_drag_starts_in_tab_region(PaneDrag *drag, double x, double y) { - if (x < 0.0 || y < 0.0 || x >= gtk_widget_get_width(drag->source) || y >= gtk_widget_get_height(drag->source)) { - return FALSE; - } - - double action_start = gtk_widget_get_width(drag->source); - if (drag->excluded_widget != NULL) { - graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); - graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point)) { - action_start = source_point.x; - } - } - - double tab_region_end = MIN(action_start - 8.0, 260.0); - return x <= tab_region_end; -} - -static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y) { - GtkWidget *picked = gtk_widget_pick(drag->source, x, y, GTK_PICK_DEFAULT); - - if (picked == NULL || picked == drag->source) { - return FALSE; - } - - if (picked_widget_is_or_contains(picked, drag->excluded_widget) || pane_drag_starts_on_action_area(drag, x, y)) { - return FALSE; - } - - return pane_drag_starts_in_tab_region(drag, x, y); -} - -static gboolean pane_drag_offset_is_significant(double offset_x, double offset_y) { - return offset_x * offset_x + offset_y * offset_y >= 64.0; -} - -static GtkWidget *create_pane_drag_preview(const char *title) { - GtkWidget *preview = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); - GtkWidget *label = gtk_label_new(title == NULL || title[0] == '\0' ? "Blank" : title); - GtkWidget *close_icon = gtk_image_new_from_icon_name("window-close-symbolic"); - - gtk_widget_add_css_class(preview, "pane-drag-preview"); - gtk_widget_set_can_target(preview, FALSE); - gtk_widget_set_halign(preview, GTK_ALIGN_START); - gtk_widget_set_valign(preview, GTK_ALIGN_START); - gtk_widget_set_size_request(preview, 170, -1); - gtk_label_set_xalign(GTK_LABEL(label), 0.5f); - gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_can_target(label, FALSE); - gtk_widget_set_can_target(close_icon, FALSE); - - gtk_box_append(GTK_BOX(preview), label); - gtk_box_append(GTK_BOX(preview), close_icon); - - return preview; -} - -static void set_pane_drag_preview_position(PaneDrag *drag, double x, double y) { - if (drag->preview == NULL) { - return; - } - - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); - graphene_point_t overlay_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (!gtk_widget_compute_point(drag->source, drag->window->content_overlay, &source_point, &overlay_point)) { - return; - } - - gtk_widget_set_margin_start(drag->preview, MAX(0, (int)overlay_point.x - 85)); - gtk_widget_set_margin_top(drag->preview, MAX(0, (int)overlay_point.y - 18)); -} - -static void show_pane_drag_preview(PaneDrag *drag) { - if (drag->preview != NULL) { - return; - } - - drag->preview = create_pane_drag_preview(drag->title); - gtk_overlay_add_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); - set_pane_drag_preview_position(drag, drag->start_x, drag->start_y); -} - -static void clear_pane_drag_preview(PaneDrag *drag) { - if (drag->preview == NULL) { - return; - } - - gtk_overlay_remove_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); - drag->preview = NULL; -} - -static void pane_drag_update(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { - (void)gesture; - - PaneDrag *drag = user_data; - GtkWidget *zone_widget = NULL; - SplitDropZone zone = {0}; - double x = drag->start_x + offset_x; - double y = drag->start_y + offset_y; - - if (!drag->active) { - if (!pane_drag_offset_is_significant(offset_x, offset_y)) { - return; - } - - drag->active = TRUE; - clear_pending_tab_activation(drag->view); - g_object_set_data(G_OBJECT(drag->view), "tab-press-active", NULL); - show_pane_drag_preview(drag); - gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); - } - - set_pane_drag_preview_position(drag, x, y); - if (pane_drag_find_zone(drag, x, y, &zone_widget, &zone)) { - set_pane_drag_highlight(drag->window, zone_widget); - } else { - set_pane_drag_highlight(drag->window, NULL); - } -} - -static void pane_drag_end(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { - (void)gesture; - - PaneDrag *drag = user_data; - GtkWidget *zone_widget = NULL; - SplitDropZone zone = {0}; - gboolean has_zone = pane_drag_find_zone(drag, drag->start_x + offset_x, drag->start_y + offset_y, &zone_widget, &zone); - - if (!drag->active) { - return; - } - - drag->active = FALSE; - set_pane_drag_highlight(drag->window, NULL); - clear_pane_drag_preview(drag); - if (!has_zone) { - return; - } - - request_move_pane( - drag->window, - drag->workspace_id, - drag->pane_id, - zone.target_pane_id, - zone.axis, - zone.new_pane_first - ); -} - -static void pane_drag_free(PaneDrag *drag) { - clear_pane_drag_preview(drag); - g_free(drag->title); - g_free(drag); -} - -static void remove_single_tab_pane_drag(GtkWidget *widget) { - GtkEventController *controller = g_object_get_data(G_OBJECT(widget), "pane-drag-controller"); - - if (controller == NULL) { - return; - } - - g_object_set_data(G_OBJECT(widget), "pane-drag-controller", NULL); - gtk_widget_remove_controller(widget, controller); -} - -static void add_single_tab_pane_drag( - KosmosMainWindow *self, - GtkWidget *widget, - GtkWidget *excluded_widget, - AdwTabView *tab_view, - guint64 workspace_id, - guint64 pane_id, - const char *title -) { - GtkGesture *gesture = gtk_gesture_drag_new(); - PaneDrag *drag = g_new0(PaneDrag, 1); - - remove_single_tab_pane_drag(widget); - - drag->window = self; - drag->view = tab_view; - drag->source = widget; - drag->excluded_widget = excluded_widget; - drag->title = g_strdup(title); - drag->workspace_id = workspace_id; - drag->pane_id = pane_id; - - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); - g_object_set_data_full(G_OBJECT(gesture), "pane-drag", drag, (GDestroyNotify)pane_drag_free); - g_signal_connect(gesture, "drag-begin", G_CALLBACK(pane_drag_begin), drag); - g_signal_connect(gesture, "drag-update", G_CALLBACK(pane_drag_update), drag); - g_signal_connect(gesture, "drag-end", G_CALLBACK(pane_drag_end), drag); - gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(gesture)); - g_object_set_data(G_OBJECT(widget), "pane-drag-controller", gesture); -} - -static void configure_single_tab_pane_drag( - KosmosMainWindow *self, - GtkWidget *tab_bar, - GtkWidget *new_tab_button, - AdwTabView *tab_view, - guint64 workspace_id, - guint64 pane_id, - int tab_count, - AdwTabPage *active_page -) { - if (tab_count == 1 && active_page != NULL) { - add_single_tab_pane_drag( - self, - tab_bar, - new_tab_button, - tab_view, - workspace_id, - pane_id, - adw_tab_page_get_title(active_page) - ); - return; - } - - remove_single_tab_pane_drag(tab_bar); -} - -static AdwTabPage *tab_page_for_id(AdwTabView *view, guint64 tab_id) { - int page_count = adw_tab_view_get_n_pages(view); - - for (int index = 0; index < page_count; index++) { - AdwTabPage *page = adw_tab_view_get_nth_page(view, index); - guint64 page_tab_id = 0; - if (get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) && page_tab_id == tab_id) { - return page; - } - } - - return NULL; -} - -static JsonObject *get_tab_snapshot(JsonArray *tabs, guint index) { - JsonNode *tab_node = json_array_get_element(tabs, index); - - if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { - return NULL; - } - - return json_node_get_object(tab_node); -} - -static gboolean tab_view_matches_snapshot_prefix(AdwTabView *view, JsonArray *tabs) { - int page_count = adw_tab_view_get_n_pages(view); - - if (json_array_get_length(tabs) < (guint)page_count) { - return FALSE; - } - - for (int index = 0; index < page_count; index++) { - AdwTabPage *page = adw_tab_view_get_nth_page(view, index); - JsonObject *tab = get_tab_snapshot(tabs, (guint)index); - guint64 page_tab_id = 0; - guint64 snapshot_tab_id = 0; - - if (tab == NULL || - !get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) || - !get_uint_member(tab, "id", &snapshot_tab_id) || - page_tab_id != snapshot_tab_id) { - return FALSE; - } - } - - return TRUE; -} - -static gboolean tab_view_matches_snapshot_exact(AdwTabView *view, JsonArray *tabs) { - return json_array_get_length(tabs) == (guint)adw_tab_view_get_n_pages(view) && - tab_view_matches_snapshot_prefix(view, tabs); -} - -static gboolean update_tab_view_from_pane_view( - KosmosMainWindow *self, - AdwTabView *view, - JsonObject *pane, - guint64 workspace_id, - guint64 active_pane_id, - gboolean allow_append -) { - guint64 pane_id = 0; - guint64 active_tab_id = 0; - JsonArray *tabs = get_array_member(pane, "tabs"); - - if (!get_uint_member(pane, "id", &pane_id) || !get_uint_member(pane, "activeTabId", &active_tab_id) || tabs == NULL) { - return FALSE; - } - - if (allow_append) { - if (!tab_view_matches_snapshot_prefix(view, tabs)) { - return FALSE; - } - } else if (!tab_view_matches_snapshot_exact(view, tabs)) { - return FALSE; - } - - gboolean was_applying = self->applying_server_state; - self->applying_server_state = TRUE; - - AdwTabPage *active_page = NULL; - guint tab_count = json_array_get_length(tabs); - for (guint index = 0; index < tab_count; index++) { - JsonObject *tab = get_tab_snapshot(tabs, index); - guint64 tab_id = 0; - const char *title = NULL; - const char *kind = NULL; - if (tab == NULL || !get_uint_member(tab, "id", &tab_id) || !get_string_member(tab, "title", &title)) { - continue; - } - get_string_member(tab, "kind", &kind); - - AdwTabPage *page = tab_page_for_id(view, tab_id); - if (page == NULL) { - if (!allow_append) { - self->applying_server_state = was_applying; - return FALSE; - } - - page = adw_tab_view_append(view, create_tab_content(kind)); - } - - configure_tab_page(page, workspace_id, pane_id, tab_id, title); - if (tab_id == active_tab_id) { - active_page = page; - } - } - - if (active_page != NULL && adw_tab_view_get_selected_page(view) != active_page) { - adw_tab_view_set_selected_page(view, active_page); - } - - (void)active_pane_id; - - GtkWidget *tab_bar = g_object_get_data(G_OBJECT(view), "tab-bar"); - GtkWidget *new_tab_button = g_object_get_data(G_OBJECT(view), "new-tab-button"); - if (tab_bar != NULL && new_tab_button != NULL) { - configure_single_tab_pane_drag( - self, - tab_bar, - new_tab_button, - view, - workspace_id, - pane_id, - (int)tab_count, - active_page - ); - } - - self->applying_server_state = was_applying; - register_pane_view(self, pane_id, view); - return TRUE; -} - -static gboolean update_tab_view_from_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, guint64 active_pane_id) { - guint64 pane_id = 0; - if (!get_uint_member(pane, "id", &pane_id)) { - return FALSE; - } - - AdwTabView *view = pane_view_for(self, pane_id); - if (view == NULL) { - return FALSE; - } - - return update_tab_view_from_pane_view(self, view, pane, workspace_id, active_pane_id, TRUE); -} - -static gboolean update_pane_node_in_place(KosmosMainWindow *self, JsonObject *node, guint64 workspace_id, guint64 active_pane_id) { - const char *type = NULL; - if (!get_string_member(node, "type", &type)) { - return FALSE; - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = get_object_member(node, "pane"); - return pane != NULL && update_tab_view_from_pane(self, pane, workspace_id, active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - JsonObject *first = get_object_member(node, "first"); - JsonObject *second = get_object_member(node, "second"); - return first != NULL && - second != NULL && - update_pane_node_in_place(self, first, workspace_id, active_pane_id) && - update_pane_node_in_place(self, second, workspace_id, active_pane_id); - } - - return FALSE; -} - -static gboolean update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace) { - guint64 workspace_id = 0; - guint64 active_pane_id = 0; - JsonObject *root = get_object_member(workspace, "root"); - - if (!get_uint_member(workspace, "id", &workspace_id) || - !get_uint_member(workspace, "activePaneId", &active_pane_id) || - root == NULL) { - return FALSE; - } - - return update_pane_node_in_place(self, root, workspace_id, active_pane_id); -} - -static void show_split_drop_highlight(GtkDropControllerMotion *controller, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - set_split_drop_highlight(controller, TRUE); -} - -static void hide_split_drop_highlight(GtkDropControllerMotion *controller, gpointer user_data) { - (void)user_data; - - set_split_drop_highlight(controller, FALSE); -} - -static GtkWidget *create_split_drop_highlight(void) { - GtkWidget *highlight = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_add_css_class(highlight, "split-drop-highlight"); - gtk_widget_set_can_target(highlight, FALSE); - gtk_widget_set_hexpand(highlight, TRUE); - gtk_widget_set_vexpand(highlight, TRUE); - gtk_widget_set_opacity(highlight, 0.0); - - return highlight; -} - -static GtkWidget *create_split_drop_target( - KosmosMainWindow *self, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - GtkWidget *target = gtk_overlay_new(); - GtkWidget *drop_receiver = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *highlight = create_split_drop_highlight(); - GtkEventController *motion = GTK_EVENT_CONTROLLER(gtk_drop_controller_motion_new()); - AdwTabBar *tab_bar = adw_tab_bar_new(); - AdwTabView *tab_view = adw_tab_view_new(); - SplitDropZone *zone = g_new(SplitDropZone, 1); - - adw_tab_bar_set_view(tab_bar, tab_view); - gtk_widget_set_visible(GTK_WIDGET(tab_bar), FALSE); - gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_opacity(GTK_WIDGET(tab_view), 0.01); - gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_bar)); - gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_view)); - gtk_overlay_set_child(GTK_OVERLAY(target), drop_receiver); - gtk_overlay_add_overlay(GTK_OVERLAY(target), highlight); - - g_object_set_data(G_OBJECT(tab_view), "split-drop-target", GINT_TO_POINTER(1)); - set_uint64_data(G_OBJECT(tab_view), "target-pane-id", target_pane_id); - g_object_set_data(G_OBJECT(tab_view), "split-axis", GINT_TO_POINTER(axis + 1)); - g_object_set_data(G_OBJECT(tab_view), "split-new-pane-first", GINT_TO_POINTER((new_pane_first ? 1 : 0) + 1)); - g_signal_connect(tab_view, "page-attached", G_CALLBACK(split_attached_tab), self); - - zone->target_pane_id = target_pane_id; - zone->axis = axis; - zone->new_pane_first = new_pane_first; - g_object_set_data_full(G_OBJECT(target), "split-drop-zone", zone, g_free); - g_object_set_data(G_OBJECT(target), "split-drop-highlight", highlight); - - g_object_set_data(G_OBJECT(motion), "split-drop-highlight", highlight); - g_signal_connect(motion, "enter", G_CALLBACK(show_split_drop_highlight), NULL); - g_signal_connect(motion, "motion", G_CALLBACK(show_split_drop_highlight), NULL); - g_signal_connect(motion, "leave", G_CALLBACK(hide_split_drop_highlight), NULL); - gtk_widget_add_controller(target, motion); - - return target; -} - -static void add_split_drop_zone( - GtkOverlay *overlay, - GtkWidget *target, - GtkAlign halign, - GtkAlign valign, - int width, - int height -) { - gtk_widget_set_halign(target, halign); - gtk_widget_set_valign(target, valign); - gtk_widget_set_size_request(target, width, height); - gtk_overlay_add_overlay(overlay, target); -} - -static GtkWidget *create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id) { - GtkWidget *overlay = gtk_overlay_new(); - gtk_widget_set_hexpand(overlay, TRUE); - gtk_widget_set_vexpand(overlay, TRUE); - gtk_overlay_set_child(GTK_OVERLAY(overlay), GTK_WIDGET(tab_view)); - - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, TRUE), - GTK_ALIGN_START, - GTK_ALIGN_FILL, - 96, - -1 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, FALSE), - GTK_ALIGN_END, - GTK_ALIGN_FILL, - 96, - -1 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, TRUE), - GTK_ALIGN_FILL, - GTK_ALIGN_START, - -1, - 96 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, FALSE), - GTK_ALIGN_FILL, - GTK_ALIGN_END, - -1, - 96 - ); - - return overlay; -} - -static GtkWidget *create_tabbed_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, gboolean is_active_pane) { - guint64 pane_id = 0; - guint64 active_tab_id = 0; - JsonArray *tabs = get_array_member(pane, "tabs"); - - if (!get_uint_member(pane, "id", &pane_id) || !get_uint_member(pane, "activeTabId", &active_tab_id) || tabs == NULL) { - return create_label("Invalid pane snapshot.", "error"); - } - - GtkWidget *container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_hexpand(container, TRUE); - gtk_widget_set_vexpand(container, TRUE); - gtk_widget_set_size_request(container, KOSMOS_MIN_PANE_WIDTH, KOSMOS_MIN_PANE_HEIGHT); - - (void)is_active_pane; - - AdwTabView *tab_view = adw_tab_view_new(); - AdwTabBar *tab_bar = adw_tab_bar_new(); - GtkWidget *new_tab_button = create_new_tab_button(workspace_id, pane_id); - adw_tab_bar_set_view(tab_bar, tab_view); - adw_tab_bar_set_end_action_widget(tab_bar, new_tab_button); - adw_tab_bar_set_autohide(tab_bar, FALSE); - adw_tab_bar_set_expand_tabs(tab_bar, FALSE); - adw_tab_view_set_shortcuts( - tab_view, - ADW_TAB_VIEW_SHORTCUT_CONTROL_TAB | - ADW_TAB_VIEW_SHORTCUT_CONTROL_SHIFT_TAB | - ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_UP | - ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_DOWN | - ADW_TAB_VIEW_SHORTCUT_ALT_DIGITS | - ADW_TAB_VIEW_SHORTCUT_ALT_ZERO - ); - gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); - - guint tab_count = json_array_get_length(tabs); - AdwTabPage *active_page = NULL; - for (guint index = 0; index < tab_count; index++) { - JsonNode *tab_node = json_array_get_element(tabs, index); - if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { - continue; - } - - JsonObject *tab = json_node_get_object(tab_node); - guint64 tab_id = 0; - const char *title = NULL; - const char *kind = NULL; - if (!get_uint_member(tab, "id", &tab_id) || !get_string_member(tab, "title", &title)) { - continue; - } - get_string_member(tab, "kind", &kind); - - GtkWidget *content = create_tab_content(kind); - AdwTabPage *page = adw_tab_view_append(tab_view, content); - configure_tab_page(page, workspace_id, pane_id, tab_id, title); - - if (tab_id == active_tab_id) { - active_page = page; - } - } - - if (active_page != NULL) { - adw_tab_view_set_selected_page(tab_view, active_page); - } - - register_pane_view(self, pane_id, tab_view); - set_uint64_data(G_OBJECT(container), "pane-id", pane_id); - g_object_set_data(G_OBJECT(container), "tab-view", tab_view); - g_object_set_data(G_OBJECT(tab_view), "pane-container", container); - g_object_set_data(G_OBJECT(tab_view), "tab-bar", GTK_WIDGET(tab_bar)); - g_object_set_data(G_OBJECT(tab_view), "new-tab-button", new_tab_button); - - add_tab_bar_activation_controller(self, GTK_WIDGET(tab_bar), tab_view); - configure_single_tab_pane_drag( - self, - GTK_WIDGET(tab_bar), - new_tab_button, - tab_view, - workspace_id, - pane_id, - (int)tab_count, - active_page - ); - - gtk_box_append(GTK_BOX(container), GTK_WIDGET(tab_bar)); - gtk_box_append(GTK_BOX(container), create_split_overlay(self, tab_view, pane_id)); - g_signal_connect(tab_view, "notify::selected-page", G_CALLBACK(activate_selected_tab), self); - g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), NULL); - g_signal_connect(tab_view, "close-page", G_CALLBACK(close_tab), self); - g_signal_connect(tab_view, "create-window", G_CALLBACK(create_split_sink_for_detached_tab), self); - g_signal_connect(tab_view, "page-detached", G_CALLBACK(tab_detached), NULL); - g_signal_connect(tab_view, "page-reordered", G_CALLBACK(reorder_tab), self); - g_signal_connect(new_tab_button, "clicked", G_CALLBACK(open_blank_tab), self); - - return container; -} - -static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer user_data) { - (void)frame_clock; - - PanedRatio *ratio = user_data; - int size = ratio->orientation == GTK_ORIENTATION_HORIZONTAL - ? gtk_widget_get_width(widget) - : gtk_widget_get_height(widget); - - if (size <= 1) { - return G_SOURCE_CONTINUE; - } - - gtk_paned_set_position(GTK_PANED(widget), (int)(size * ratio->ratio)); - return G_SOURCE_REMOVE; -} - -static void set_paned_ratio_after_layout(GtkWidget *paned, GtkOrientation orientation, double ratio) { - PanedRatio *ratio_data = g_new(PanedRatio, 1); - ratio_data->orientation = orientation; - ratio_data->ratio = CLAMP(ratio, 0.05, 0.95); - gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, g_free); -} - -static GtkWidget *create_pane_node( - KosmosMainWindow *self, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path, - GHashTable *reusable_panes -) { - const char *type = NULL; - if (!get_string_member(node, "type", &type)) { - return create_label("Invalid pane node.", "error"); - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { - return create_label("Invalid pane leaf.", "error"); - } - - GtkWidget *reusable_pane = reusable_panes == NULL ? NULL : g_hash_table_lookup(reusable_panes, &pane_id); - if (reusable_pane != NULL && gtk_widget_get_parent(reusable_pane) == NULL) { - AdwTabView *tab_view = g_object_get_data(G_OBJECT(reusable_pane), "tab-view"); - if (tab_view != NULL && update_tab_view_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { - return reusable_pane; - } - } - - return create_tabbed_pane(self, pane, workspace_id, pane_id == active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - double ratio = 0.5; - JsonObject *first = get_object_member(node, "first"); - JsonObject *second = get_object_member(node, "second"); - if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { - return create_label("Invalid pane split.", "error"); - } - - get_double_member(node, "ratio", &ratio); - get_paned_ratio(self, path, &ratio); - - GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 - ? GTK_ORIENTATION_VERTICAL - : GTK_ORIENTATION_HORIZONTAL; - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - GtkWidget *paned = gtk_paned_new(orientation); - gtk_widget_set_hexpand(paned, TRUE); - gtk_widget_set_vexpand(paned, TRUE); - gtk_paned_set_shrink_start_child(GTK_PANED(paned), FALSE); - gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); - g_object_set_data_full(G_OBJECT(paned), "pane-path", g_strdup(path), g_free); - gtk_paned_set_start_child(GTK_PANED(paned), create_pane_node(self, first, workspace_id, active_pane_id, first_path, reusable_panes)); - gtk_paned_set_end_child(GTK_PANED(paned), create_pane_node(self, second, workspace_id, active_pane_id, second_path, reusable_panes)); - set_paned_ratio_after_layout(paned, orientation, ratio); - g_free(first_path); - g_free(second_path); - return paned; - } - - return create_label("Unsupported pane node.", "error"); -} - -static gboolean widget_is_pane_leaf(GtkWidget *widget, guint64 pane_id) { - guint64 widget_pane_id = 0; - - return get_uint64_data(G_OBJECT(widget), "pane-id", &widget_pane_id) && widget_pane_id == pane_id; -} - -static GtkWidget *reconcile_pane_node( - KosmosMainWindow *self, - GtkWidget *current, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path -) { - const char *type = NULL; - if (!get_string_member(node, "type", &type)) { - return create_label("Invalid pane node.", "error"); - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !get_uint_member(pane, "id", &pane_id)) { - return create_label("Invalid pane leaf.", "error"); - } - - if (current != NULL && widget_is_pane_leaf(current, pane_id)) { - AdwTabView *tab_view = g_object_get_data(G_OBJECT(current), "tab-view"); - if (tab_view != NULL && update_tab_view_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { - return current; - } - } - - return create_tabbed_pane(self, pane, workspace_id, pane_id == active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - double ratio = 0.5; - JsonObject *first = get_object_member(node, "first"); - JsonObject *second = get_object_member(node, "second"); - if (!get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { - return create_label("Invalid pane split.", "error"); - } - - get_double_member(node, "ratio", &ratio); - get_paned_ratio(self, path, &ratio); - - GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 - ? GTK_ORIENTATION_VERTICAL - : GTK_ORIENTATION_HORIZONTAL; - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - - if (!GTK_IS_PANED(current)) { - GtkWidget *created = create_pane_node(self, node, workspace_id, active_pane_id, path, NULL); - g_free(first_path); - g_free(second_path); - return created; - } - - gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); - gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); - gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); - g_object_set_data_full(G_OBJECT(current), "pane-path", g_strdup(path), g_free); - - GtkWidget *old_start = gtk_paned_get_start_child(GTK_PANED(current)); - GtkWidget *new_start = reconcile_pane_node(self, old_start, first, workspace_id, active_pane_id, first_path); - if (new_start != old_start) { - gtk_paned_set_start_child(GTK_PANED(current), new_start); - } - - GtkWidget *old_end = gtk_paned_get_end_child(GTK_PANED(current)); - GtkWidget *new_end = reconcile_pane_node(self, old_end, second, workspace_id, active_pane_id, second_path); - if (new_end != old_end) { - gtk_paned_set_end_child(GTK_PANED(current), new_end); - } - - set_paned_ratio_after_layout(current, orientation, ratio); - g_free(first_path); - g_free(second_path); - return current; - } - - return create_label("Unsupported pane node.", "error"); -} - -static void render_active_workspace(KosmosMainWindow *self, JsonObject *workspace) { - remember_paned_ratios(self); - - if (workspace == NULL) { - g_hash_table_remove_all(self->paned_ratios); - clear_content_area(self); - return; - } - - guint64 workspace_id = 0; - guint64 active_pane_id = 0; - JsonObject *root = get_object_member(workspace, "root"); - - if (!get_uint_member(workspace, "id", &workspace_id) || - !get_uint_member(workspace, "activePaneId", &active_pane_id) || - root == NULL) { - set_status(self, "Invalid workspace snapshot."); - return; - } - - prune_paned_ratios_for_root(self, root); - - GtkWidget *current_root = gtk_widget_get_first_child(self->content_area); - if (current_root != NULL) { - g_hash_table_remove_all(self->pane_views); - GtkWidget *pane_tree = reconcile_pane_node(self, current_root, root, workspace_id, active_pane_id, "root"); - if (pane_tree != current_root) { - gtk_box_remove(GTK_BOX(self->content_area), current_root); - gtk_box_append(GTK_BOX(self->content_area), pane_tree); - } - return; - } - - GtkWidget *pane_tree = create_pane_node(self, root, workspace_id, active_pane_id, "root", NULL); - gtk_box_append(GTK_BOX(self->content_area), pane_tree); -} - -static void apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { - if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { - set_status(self, "Workspace state is unavailable."); - return; - } - - JsonObject *result = json_node_get_object(state); - JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); - if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { - set_status(self, "Workspace state is missing workspaces."); - return; - } - - guint64 active_workspace_id = 0; - gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); - JsonArray *workspaces = json_node_get_array(workspaces_node); - guint workspace_count = json_array_get_length(workspaces); - JsonObject *active_workspace = find_active_workspace(workspaces, active_workspace_id, has_active_workspace); - GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - GtkWidget *previous_button = NULL; - - for (guint index = 0; index < workspace_count; index++) { - JsonNode *workspace_node = json_array_get_element(workspaces, index); - if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { - JsonObject *workspace = json_node_get_object(workspace_node); - guint64 workspace_id = 0; - - if (!get_uint_member(workspace, "id", &workspace_id)) { - continue; - } - - guint64 *seen_id = g_new(guint64, 1); - *seen_id = workspace_id; - g_hash_table_add(seen_workspace_ids, seen_id); - - GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); - if (button != NULL) { - gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); - previous_button = button; - } - } - } - - remove_stale_workspace_buttons(self, seen_workspace_ids); - gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), ensure_add_workspace_button(self, TRUE), previous_button); - g_hash_table_unref(seen_workspace_ids); - - (void)workspace_count; - if (active_workspace != NULL) { - char *layout_signature = create_layout_signature(active_workspace); - if (layout_signature != NULL && - self->layout_signature != NULL && - g_strcmp0(layout_signature, self->layout_signature) == 0 && - update_active_workspace_in_place(self, active_workspace)) { - g_free(layout_signature); - return; - } - - render_active_workspace(self, active_workspace); - g_clear_pointer(&self->layout_signature, g_free); - self->layout_signature = layout_signature; - return; - } - - render_active_workspace(self, active_workspace); -} - -static GtkWidget *create_workspace_switcher(KosmosMainWindow *self) { - self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_add_css_class(self->workspace_switcher, "linked"); - gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); - ensure_add_workspace_button(self, FALSE); - - return self->workspace_switcher; + kosmos_main_window_apply_workspace_state(self, state); } static GtkWidget *create_header(KosmosMainWindow *self) { GtkWidget *header = gtk_header_bar_new(); - gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), create_workspace_switcher(self)); + gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), kosmos_workspace_switcher_create(self)); return header; } @@ -2165,7 +174,7 @@ static void kosmos_main_window_init(KosmosMainWindow *self) { self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); self->paned_ratios = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); self->pane_views = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - install_split_drop_css(GTK_WIDGET(self)); + kosmos_pane_dnd_install_css(GTK_WIDGET(self)); gtk_window_set_title(GTK_WINDOW(self), "Kosmos"); gtk_window_set_default_size(GTK_WINDOW(self), 1000, 700); @@ -2188,7 +197,7 @@ static void kosmos_main_window_init(KosmosMainWindow *self) { static void kosmos_main_window_finalize(GObject *object) { KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(object); - clear_detached_tab_transfer(self); + kosmos_pane_dnd_clear_detached_tab_transfer(self); g_clear_object(&self->ipc_client); g_clear_pointer(&self->workspace_buttons, g_hash_table_unref); g_clear_pointer(&self->paned_ratios, g_hash_table_unref); @@ -2221,19 +230,19 @@ GtkWidget *kosmos_main_window_new(GtkApplication *application, KosmosIpcClient * void kosmos_main_window_refresh_workspace_state(KosmosMainWindow *self) { g_return_if_fail(KOSMOS_IS_MAIN_WINDOW(self)); - if (!ensure_connected(self)) { - ensure_add_workspace_button(self, FALSE); + if (!kosmos_main_window_ensure_connected(self)) { + kosmos_workspace_switcher_ensure_add_button(self, FALSE); return; } GError *error = NULL; JsonNode *state = NULL; if (!kosmos_ipc_client_list_workspaces(self->ipc_client, &state, NULL, &error)) { - set_error_status(self, "Failed to load workspaces", error); + kosmos_main_window_set_error_status(self, "Failed to load workspaces", error); g_clear_error(&error); return; } - apply_workspace_state(self, state); + kosmos_main_window_apply_workspace_state(self, state); json_node_unref(state); } diff --git a/desktop/src/ui/kosmos_main_window_private.h b/desktop/src/ui/kosmos_main_window_private.h new file mode 100644 index 00000000..5de9174e --- /dev/null +++ b/desktop/src/ui/kosmos_main_window_private.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include "ui/kosmos_main_window.h" + +#define KOSMOS_MIN_PANE_WIDTH 220 +#define KOSMOS_MIN_PANE_HEIGHT 160 + +struct _KosmosMainWindow { + GtkApplicationWindow parent_instance; + KosmosIpcClient *ipc_client; + GtkWidget *workspace_switcher; + GtkWidget *add_workspace_button; + GtkWidget *content_overlay; + GtkWidget *content_area; + GHashTable *workspace_buttons; + GHashTable *paned_ratios; + GHashTable *pane_views; + char *layout_signature; + AdwTabView *detached_tab_sink; + GtkWindow *detached_tab_window; + gboolean splitting_detached_tab; + gboolean suppress_tab_detach; + gboolean applying_server_state; +}; + +typedef struct { + guint64 target_pane_id; + KosmosIpcSplitAxis axis; + gboolean new_pane_first; +} KosmosSplitDropZone; + +GtkWidget *kosmos_main_window_create_label(const char *text, const char *css_class); +void kosmos_main_window_clear_content_area(KosmosMainWindow *self); + +void kosmos_main_window_set_uint64_data(GObject *object, const char *key, guint64 value); +gboolean kosmos_main_window_get_uint64_data(GObject *object, const char *key, guint64 *value); + +void kosmos_main_window_register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view); +AdwTabView *kosmos_main_window_pane_view_for(KosmosMainWindow *self, guint64 pane_id); + +gboolean kosmos_json_get_uint_member(JsonObject *object, const char *name, guint64 *value); +gboolean kosmos_json_get_string_member(JsonObject *object, const char *name, const char **value); +gboolean kosmos_json_get_double_member(JsonObject *object, const char *name, double *value); +JsonObject *kosmos_json_get_object_member(JsonObject *object, const char *name); +JsonArray *kosmos_json_get_array_member(JsonObject *object, const char *name); + +void kosmos_main_window_set_status(KosmosMainWindow *self, const char *status); +void kosmos_main_window_set_error_status(KosmosMainWindow *self, const char *prefix, GError *error); +gboolean kosmos_main_window_ensure_connected(KosmosMainWindow *self); +void kosmos_main_window_apply_server_state_or_show_error( + KosmosMainWindow *self, + JsonNode *state, + GError *error, + const char *error_prefix +); +void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode *state); + +GtkWidget *kosmos_workspace_switcher_create(KosmosMainWindow *self); +GtkWidget *kosmos_workspace_switcher_ensure_add_button(KosmosMainWindow *self, gboolean sensitive); +void kosmos_workspace_switcher_clear(KosmosMainWindow *self); + +char *kosmos_pane_layout_create_signature(JsonObject *workspace); +gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace); +void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace); + +GtkWidget *kosmos_tabbed_pane_create( + KosmosMainWindow *self, + JsonObject *pane, + guint64 workspace_id, + gboolean is_active_pane +); +gboolean kosmos_tabbed_pane_update_from_pane( + KosmosMainWindow *self, + JsonObject *pane, + guint64 workspace_id, + guint64 active_pane_id +); +gboolean kosmos_tabbed_pane_update_from_pane_view( + KosmosMainWindow *self, + AdwTabView *view, + JsonObject *pane, + guint64 workspace_id, + guint64 active_pane_id, + gboolean allow_append +); +void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view); + +void kosmos_pane_dnd_install_css(GtkWidget *widget); +GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id); +AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data); +void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self); +void kosmos_pane_dnd_configure_single_tab( + KosmosMainWindow *self, + GtkWidget *tab_bar, + GtkWidget *new_tab_button, + AdwTabView *tab_view, + guint64 workspace_id, + guint64 pane_id, + int tab_count, + AdwTabPage *active_page +); diff --git a/desktop/src/ui/kosmos_pane_dnd.c b/desktop/src/ui/kosmos_pane_dnd.c new file mode 100644 index 00000000..23a920ca --- /dev/null +++ b/desktop/src/ui/kosmos_pane_dnd.c @@ -0,0 +1,702 @@ +#include "ui/kosmos_main_window_private.h" + +typedef struct { + KosmosMainWindow *window; + guint64 workspace_id; + guint64 pane_id; + guint64 target_pane_id; + guint64 tab_id; + KosmosIpcSplitAxis axis; + gboolean new_pane_first; +} DetachedTabSplit; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; + GtkWidget *source; + GtkWidget *excluded_widget; + GtkWidget *preview; + char *title; + guint64 workspace_id; + guint64 pane_id; + double start_x; + double start_y; + gboolean active; +} PaneDrag; + +static void show_pane_drag_preview(PaneDrag *drag); +static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y); + +void kosmos_pane_dnd_install_css(GtkWidget *widget) { + static gboolean installed = FALSE; + + if (installed) { + return; + } + + GtkCssProvider *provider = gtk_css_provider_new(); + gtk_css_provider_load_from_string( + provider, + ".split-drop-highlight {" + " background: rgba(53, 132, 228, 0.24);" + " border: 2px solid rgba(53, 132, 228, 0.72);" + " border-radius: 10px;" + "}" + ".pane-drag-preview {" + " background: rgba(98, 99, 107, 0.96);" + " border-radius: 9px;" + " padding: 8px 12px;" + " box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);" + "}" + ); + gtk_style_context_add_provider_for_display( + gtk_widget_get_display(widget), + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + ); + g_object_unref(provider); + installed = TRUE; +} + +static void detached_tab_split_free(DetachedTabSplit *split) { + g_object_unref(split->window); + g_free(split); +} + +void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self) { + if (self->detached_tab_window != NULL) { + gtk_window_destroy(self->detached_tab_window); + } + + g_clear_object(&self->detached_tab_window); + g_clear_object(&self->detached_tab_sink); +} + +static void request_split_tab( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + guint64 tab_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_split_tab( + self->ipc_client, + workspace_id, + pane_id, + target_pane_id, + tab_id, + axis, + new_pane_first, + &state, + NULL, + &error + ); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to split tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static void request_move_pane( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_move_pane( + self->ipc_client, + workspace_id, + pane_id, + target_pane_id, + axis, + new_pane_first, + &state, + NULL, + &error + ); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to move pane"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static gboolean finish_detached_tab_split(gpointer user_data) { + DetachedTabSplit *split = user_data; + KosmosMainWindow *self = split->window; + + request_split_tab( + self, + split->workspace_id, + split->pane_id, + split->target_pane_id, + split->tab_id, + split->axis, + split->new_pane_first + ); + + kosmos_pane_dnd_clear_detached_tab_transfer(self); + return G_SOURCE_REMOVE; +} + +static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + (void)position; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + gboolean is_split_target = g_object_get_data(G_OBJECT(view), "split-drop-target") != NULL; + + if (!self->splitting_detached_tab && !is_split_target) { + return; + } + + self->splitting_detached_tab = FALSE; + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { + kosmos_main_window_refresh_workspace_state(self); + return; + } + + guint64 target_pane_id = pane_id; + kosmos_main_window_get_uint64_data(G_OBJECT(view), "target-pane-id", &target_pane_id); + + gpointer axis_data = g_object_get_data(G_OBJECT(view), "split-axis"); + KosmosIpcSplitAxis axis = axis_data == NULL + ? KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL + : (KosmosIpcSplitAxis)(GPOINTER_TO_INT(axis_data) - 1); + + gpointer new_pane_first_data = g_object_get_data(G_OBJECT(view), "split-new-pane-first"); + gboolean new_pane_first = new_pane_first_data == NULL + ? FALSE + : GPOINTER_TO_INT(new_pane_first_data) - 1; + + DetachedTabSplit *split = g_new(DetachedTabSplit, 1); + split->window = g_object_ref(self); + split->workspace_id = workspace_id; + split->pane_id = pane_id; + split->target_pane_id = target_pane_id; + split->tab_id = tab_id; + split->axis = axis; + split->new_pane_first = new_pane_first; + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); +} + +AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + kosmos_tabbed_pane_clear_pending_activation(view); + kosmos_pane_dnd_clear_detached_tab_transfer(self); + + GtkWidget *window = adw_window_new(); + AdwTabView *sink = adw_tab_view_new(); + + self->splitting_detached_tab = TRUE; + self->detached_tab_window = GTK_WINDOW(g_object_ref_sink(window)); + self->detached_tab_sink = ADW_TAB_VIEW(g_object_ref_sink(sink)); + + g_object_set_data(G_OBJECT(sink), "split-drop-target", GINT_TO_POINTER(1)); + gtk_window_set_child(self->detached_tab_window, GTK_WIDGET(sink)); + gtk_window_set_transient_for(self->detached_tab_window, GTK_WINDOW(self)); + g_signal_connect(sink, "page-attached", G_CALLBACK(split_attached_tab), self); + gtk_window_present(self->detached_tab_window); + + return sink; +} + +static void set_split_drop_highlight(GtkDropControllerMotion *controller, gboolean highlighted) { + GtkWidget *highlight = g_object_get_data(G_OBJECT(controller), "split-drop-highlight"); + + if (highlight != NULL) { + gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); + } +} + +static void set_split_zone_highlight(GtkWidget *zone, gboolean highlighted) { + GtkWidget *highlight = g_object_get_data(G_OBJECT(zone), "split-drop-highlight"); + + if (highlight != NULL) { + gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); + } +} + +static void set_pane_drag_highlight(KosmosMainWindow *self, GtkWidget *zone) { + GtkWidget *current_zone = g_object_get_data(G_OBJECT(self), "pane-drag-highlight-zone"); + + if (current_zone == zone) { + return; + } + + if (current_zone != NULL) { + set_split_zone_highlight(current_zone, FALSE); + } + + if (zone != NULL) { + set_split_zone_highlight(zone, TRUE); + } + + g_object_set_data(G_OBJECT(self), "pane-drag-highlight-zone", zone); +} + +static gboolean split_zone_contains_point(GtkWidget *zone, GtkWidget *source, double source_x, double source_y) { + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)source_x, (float)source_y); + graphene_point_t zone_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + + if (!gtk_widget_compute_point(source, zone, &source_point, &zone_point)) { + return FALSE; + } + + return zone_point.x >= 0.0f && + zone_point.y >= 0.0f && + zone_point.x < gtk_widget_get_width(zone) && + zone_point.y < gtk_widget_get_height(zone); +} + +static gboolean find_split_zone_at( + GtkWidget *widget, + GtkWidget *source, + double source_x, + double source_y, + guint64 source_pane_id, + GtkWidget **zone_widget, + KosmosSplitDropZone *zone +) { + if (!gtk_widget_get_visible(widget)) { + return FALSE; + } + + KosmosSplitDropZone *candidate = g_object_get_data(G_OBJECT(widget), "split-drop-zone"); + if (candidate != NULL && + candidate->target_pane_id != source_pane_id && + split_zone_contains_point(widget, source, source_x, source_y)) { + *zone_widget = widget; + *zone = *candidate; + return TRUE; + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + if (find_split_zone_at(child, source, source_x, source_y, source_pane_id, zone_widget, zone)) { + return TRUE; + } + } + + return FALSE; +} + +static void pane_drag_begin(GtkGestureDrag *gesture, double start_x, double start_y, gpointer user_data) { + PaneDrag *drag = user_data; + + drag->active = FALSE; + if (!pane_drag_start_is_valid(drag, start_x, start_y)) { + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED); + return; + } + + drag->start_x = start_x; + drag->start_y = start_y; + set_pane_drag_highlight(drag->window, NULL); +} + +static gboolean pane_drag_find_zone(PaneDrag *drag, double x, double y, GtkWidget **zone_widget, KosmosSplitDropZone *zone) { + return find_split_zone_at( + drag->window->content_area, + drag->source, + x, + y, + drag->pane_id, + zone_widget, + zone + ); +} + +static gboolean picked_widget_is_or_contains(GtkWidget *picked, GtkWidget *widget) { + if (widget == NULL || picked == NULL) { + return FALSE; + } + + return picked == widget || gtk_widget_is_ancestor(picked, widget); +} + +static gboolean pane_drag_starts_on_action_area(PaneDrag *drag, double x, double y) { + if (drag->excluded_widget == NULL) { + return FALSE; + } + + graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); + graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point) && x >= source_point.x - 8.0) { + return TRUE; + } + + return split_zone_contains_point(drag->excluded_widget, drag->source, x, y); +} + +static gboolean pane_drag_starts_in_tab_region(PaneDrag *drag, double x, double y) { + if (x < 0.0 || y < 0.0 || x >= gtk_widget_get_width(drag->source) || y >= gtk_widget_get_height(drag->source)) { + return FALSE; + } + + double action_start = gtk_widget_get_width(drag->source); + if (drag->excluded_widget != NULL) { + graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); + graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point)) { + action_start = source_point.x; + } + } + + double tab_region_end = MIN(action_start - 8.0, 260.0); + return x <= tab_region_end; +} + +static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y) { + GtkWidget *picked = gtk_widget_pick(drag->source, x, y, GTK_PICK_DEFAULT); + + if (picked == NULL || picked == drag->source) { + return FALSE; + } + + if (picked_widget_is_or_contains(picked, drag->excluded_widget) || pane_drag_starts_on_action_area(drag, x, y)) { + return FALSE; + } + + return pane_drag_starts_in_tab_region(drag, x, y); +} + +static gboolean pane_drag_offset_is_significant(double offset_x, double offset_y) { + return offset_x * offset_x + offset_y * offset_y >= 64.0; +} + +static GtkWidget *create_pane_drag_preview(const char *title) { + GtkWidget *preview = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + GtkWidget *label = gtk_label_new(title == NULL || title[0] == '\0' ? "Blank" : title); + GtkWidget *close_icon = gtk_image_new_from_icon_name("window-close-symbolic"); + + gtk_widget_add_css_class(preview, "pane-drag-preview"); + gtk_widget_set_can_target(preview, FALSE); + gtk_widget_set_halign(preview, GTK_ALIGN_START); + gtk_widget_set_valign(preview, GTK_ALIGN_START); + gtk_widget_set_size_request(preview, 170, -1); + gtk_label_set_xalign(GTK_LABEL(label), 0.5f); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_can_target(label, FALSE); + gtk_widget_set_can_target(close_icon, FALSE); + + gtk_box_append(GTK_BOX(preview), label); + gtk_box_append(GTK_BOX(preview), close_icon); + + return preview; +} + +static void set_pane_drag_preview_position(PaneDrag *drag, double x, double y) { + if (drag->preview == NULL) { + return; + } + + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t overlay_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (!gtk_widget_compute_point(drag->source, drag->window->content_overlay, &source_point, &overlay_point)) { + return; + } + + gtk_widget_set_margin_start(drag->preview, MAX(0, (int)overlay_point.x - 85)); + gtk_widget_set_margin_top(drag->preview, MAX(0, (int)overlay_point.y - 18)); +} + +static void show_pane_drag_preview(PaneDrag *drag) { + if (drag->preview != NULL) { + return; + } + + drag->preview = create_pane_drag_preview(drag->title); + gtk_overlay_add_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); + set_pane_drag_preview_position(drag, drag->start_x, drag->start_y); +} + +static void clear_pane_drag_preview(PaneDrag *drag) { + if (drag->preview == NULL) { + return; + } + + gtk_overlay_remove_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); + drag->preview = NULL; +} + +static void pane_drag_update(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { + PaneDrag *drag = user_data; + GtkWidget *zone_widget = NULL; + KosmosSplitDropZone zone = {0}; + double x = drag->start_x + offset_x; + double y = drag->start_y + offset_y; + + if (!drag->active) { + if (!pane_drag_offset_is_significant(offset_x, offset_y)) { + return; + } + + drag->active = TRUE; + kosmos_tabbed_pane_clear_pending_activation(drag->view); + g_object_set_data(G_OBJECT(drag->view), "tab-press-active", NULL); + show_pane_drag_preview(drag); + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); + } + + set_pane_drag_preview_position(drag, x, y); + if (pane_drag_find_zone(drag, x, y, &zone_widget, &zone)) { + set_pane_drag_highlight(drag->window, zone_widget); + } else { + set_pane_drag_highlight(drag->window, NULL); + } +} + +static void pane_drag_end(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { + (void)gesture; + + PaneDrag *drag = user_data; + GtkWidget *zone_widget = NULL; + KosmosSplitDropZone zone = {0}; + gboolean has_zone = pane_drag_find_zone(drag, drag->start_x + offset_x, drag->start_y + offset_y, &zone_widget, &zone); + + if (!drag->active) { + return; + } + + drag->active = FALSE; + set_pane_drag_highlight(drag->window, NULL); + clear_pane_drag_preview(drag); + if (!has_zone) { + return; + } + + request_move_pane( + drag->window, + drag->workspace_id, + drag->pane_id, + zone.target_pane_id, + zone.axis, + zone.new_pane_first + ); +} + +static void pane_drag_free(PaneDrag *drag) { + clear_pane_drag_preview(drag); + g_free(drag->title); + g_free(drag); +} + +static void remove_single_tab_pane_drag(GtkWidget *widget) { + GtkEventController *controller = g_object_get_data(G_OBJECT(widget), "pane-drag-controller"); + + if (controller == NULL) { + return; + } + + g_object_set_data(G_OBJECT(widget), "pane-drag-controller", NULL); + gtk_widget_remove_controller(widget, controller); +} + +static void add_single_tab_pane_drag( + KosmosMainWindow *self, + GtkWidget *widget, + GtkWidget *excluded_widget, + AdwTabView *tab_view, + guint64 workspace_id, + guint64 pane_id, + const char *title +) { + GtkGesture *gesture = gtk_gesture_drag_new(); + PaneDrag *drag = g_new0(PaneDrag, 1); + + remove_single_tab_pane_drag(widget); + + drag->window = self; + drag->view = tab_view; + drag->source = widget; + drag->excluded_widget = excluded_widget; + drag->title = g_strdup(title); + drag->workspace_id = workspace_id; + drag->pane_id = pane_id; + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); + g_object_set_data_full(G_OBJECT(gesture), "pane-drag", drag, (GDestroyNotify)pane_drag_free); + g_signal_connect(gesture, "drag-begin", G_CALLBACK(pane_drag_begin), drag); + g_signal_connect(gesture, "drag-update", G_CALLBACK(pane_drag_update), drag); + g_signal_connect(gesture, "drag-end", G_CALLBACK(pane_drag_end), drag); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(gesture)); + g_object_set_data(G_OBJECT(widget), "pane-drag-controller", gesture); +} + +void kosmos_pane_dnd_configure_single_tab( + KosmosMainWindow *self, + GtkWidget *tab_bar, + GtkWidget *new_tab_button, + AdwTabView *tab_view, + guint64 workspace_id, + guint64 pane_id, + int tab_count, + AdwTabPage *active_page +) { + if (tab_count == 1 && active_page != NULL) { + add_single_tab_pane_drag( + self, + tab_bar, + new_tab_button, + tab_view, + workspace_id, + pane_id, + adw_tab_page_get_title(active_page) + ); + return; + } + + remove_single_tab_pane_drag(tab_bar); +} + +static void show_split_drop_highlight(GtkDropControllerMotion *controller, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + set_split_drop_highlight(controller, TRUE); +} + +static void hide_split_drop_highlight(GtkDropControllerMotion *controller, gpointer user_data) { + (void)user_data; + + set_split_drop_highlight(controller, FALSE); +} + +static GtkWidget *create_split_drop_highlight(void) { + GtkWidget *highlight = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(highlight, "split-drop-highlight"); + gtk_widget_set_can_target(highlight, FALSE); + gtk_widget_set_hexpand(highlight, TRUE); + gtk_widget_set_vexpand(highlight, TRUE); + gtk_widget_set_opacity(highlight, 0.0); + + return highlight; +} + +static GtkWidget *create_split_drop_target( + KosmosMainWindow *self, + guint64 target_pane_id, + KosmosIpcSplitAxis axis, + gboolean new_pane_first +) { + GtkWidget *target = gtk_overlay_new(); + GtkWidget *drop_receiver = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *highlight = create_split_drop_highlight(); + GtkEventController *motion = GTK_EVENT_CONTROLLER(gtk_drop_controller_motion_new()); + AdwTabBar *tab_bar = adw_tab_bar_new(); + AdwTabView *tab_view = adw_tab_view_new(); + KosmosSplitDropZone *zone = g_new(KosmosSplitDropZone, 1); + + adw_tab_bar_set_view(tab_bar, tab_view); + gtk_widget_set_visible(GTK_WIDGET(tab_bar), FALSE); + gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_opacity(GTK_WIDGET(tab_view), 0.01); + gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_bar)); + gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_view)); + gtk_overlay_set_child(GTK_OVERLAY(target), drop_receiver); + gtk_overlay_add_overlay(GTK_OVERLAY(target), highlight); + + g_object_set_data(G_OBJECT(tab_view), "split-drop-target", GINT_TO_POINTER(1)); + kosmos_main_window_set_uint64_data(G_OBJECT(tab_view), "target-pane-id", target_pane_id); + g_object_set_data(G_OBJECT(tab_view), "split-axis", GINT_TO_POINTER(axis + 1)); + g_object_set_data(G_OBJECT(tab_view), "split-new-pane-first", GINT_TO_POINTER((new_pane_first ? 1 : 0) + 1)); + g_signal_connect(tab_view, "page-attached", G_CALLBACK(split_attached_tab), self); + + zone->target_pane_id = target_pane_id; + zone->axis = axis; + zone->new_pane_first = new_pane_first; + g_object_set_data_full(G_OBJECT(target), "split-drop-zone", zone, g_free); + g_object_set_data(G_OBJECT(target), "split-drop-highlight", highlight); + + g_object_set_data(G_OBJECT(motion), "split-drop-highlight", highlight); + g_signal_connect(motion, "enter", G_CALLBACK(show_split_drop_highlight), NULL); + g_signal_connect(motion, "motion", G_CALLBACK(show_split_drop_highlight), NULL); + g_signal_connect(motion, "leave", G_CALLBACK(hide_split_drop_highlight), NULL); + gtk_widget_add_controller(target, motion); + + return target; +} + +static void add_split_drop_zone( + GtkOverlay *overlay, + GtkWidget *target, + GtkAlign halign, + GtkAlign valign, + int width, + int height +) { + gtk_widget_set_halign(target, halign); + gtk_widget_set_valign(target, valign); + gtk_widget_set_size_request(target, width, height); + gtk_overlay_add_overlay(overlay, target); +} + +GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id) { + GtkWidget *overlay = gtk_overlay_new(); + gtk_widget_set_hexpand(overlay, TRUE); + gtk_widget_set_vexpand(overlay, TRUE); + gtk_overlay_set_child(GTK_OVERLAY(overlay), GTK_WIDGET(tab_view)); + + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, TRUE), + GTK_ALIGN_START, + GTK_ALIGN_FILL, + 96, + -1 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, FALSE), + GTK_ALIGN_END, + GTK_ALIGN_FILL, + 96, + -1 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, TRUE), + GTK_ALIGN_FILL, + GTK_ALIGN_START, + -1, + 96 + ); + add_split_drop_zone( + GTK_OVERLAY(overlay), + create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, FALSE), + GTK_ALIGN_FILL, + GTK_ALIGN_END, + -1, + 96 + ); + + return overlay; +} diff --git a/desktop/src/ui/kosmos_pane_layout.c b/desktop/src/ui/kosmos_pane_layout.c new file mode 100644 index 00000000..1c442b30 --- /dev/null +++ b/desktop/src/ui/kosmos_pane_layout.c @@ -0,0 +1,416 @@ +#include "ui/kosmos_main_window_private.h" + +typedef struct { + GtkOrientation orientation; + double ratio; +} PanedRatio; + +static void set_paned_ratio(KosmosMainWindow *self, const char *path, double ratio) { + double *stored_ratio = g_new(double, 1); + *stored_ratio = CLAMP(ratio, 0.05, 0.95); + g_hash_table_replace(self->paned_ratios, g_strdup(path), stored_ratio); +} + +static gboolean get_paned_ratio(KosmosMainWindow *self, const char *path, double *ratio) { + double *stored_ratio = g_hash_table_lookup(self->paned_ratios, path); + + if (stored_ratio == NULL) { + return FALSE; + } + + *ratio = *stored_ratio; + return TRUE; +} + +static void remember_paned_ratios_from_widget(KosmosMainWindow *self, GtkWidget *widget) { + if (GTK_IS_PANED(widget)) { + const char *path = g_object_get_data(G_OBJECT(widget), "pane-path"); + GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); + int size = orientation == GTK_ORIENTATION_HORIZONTAL + ? gtk_widget_get_width(widget) + : gtk_widget_get_height(widget); + + if (path != NULL && size > 1) { + set_paned_ratio(self, path, (double)gtk_paned_get_position(GTK_PANED(widget)) / size); + } + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + remember_paned_ratios_from_widget(self, child); + } +} + +static void remember_paned_ratios(KosmosMainWindow *self) { + if (self->content_area == NULL) { + return; + } + + remember_paned_ratios_from_widget(self, self->content_area); +} + +static gboolean append_layout_signature(GString *signature, JsonObject *node) { + const char *type = NULL; + if (!kosmos_json_get_string_member(node, "type", &type)) { + return FALSE; + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = kosmos_json_get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { + return FALSE; + } + + g_string_append_printf(signature, "L:%" G_GUINT64_FORMAT, pane_id); + return TRUE; + } + + if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + JsonObject *first = kosmos_json_get_object_member(node, "first"); + JsonObject *second = kosmos_json_get_object_member(node, "second"); + if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return FALSE; + } + + g_string_append_printf(signature, "S:%s(", axis); + if (!append_layout_signature(signature, first)) { + return FALSE; + } + g_string_append_c(signature, ','); + if (!append_layout_signature(signature, second)) { + return FALSE; + } + g_string_append_c(signature, ')'); + return TRUE; + } + + return FALSE; +} + +char *kosmos_pane_layout_create_signature(JsonObject *workspace) { + JsonObject *root = kosmos_json_get_object_member(workspace, "root"); + if (root == NULL) { + return NULL; + } + + GString *signature = g_string_new(NULL); + if (!append_layout_signature(signature, root)) { + g_string_free(signature, TRUE); + return NULL; + } + + return g_string_free(signature, FALSE); +} + +static void collect_split_paths(JsonObject *node, const char *path, GHashTable *split_paths) { + const char *type = NULL; + if (!kosmos_json_get_string_member(node, "type", &type)) { + return; + } + + if (g_strcmp0(type, "split") != 0) { + return; + } + + JsonObject *first = kosmos_json_get_object_member(node, "first"); + JsonObject *second = kosmos_json_get_object_member(node, "second"); + if (first == NULL || second == NULL) { + return; + } + + g_hash_table_add(split_paths, g_strdup(path)); + + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + collect_split_paths(first, first_path, split_paths); + collect_split_paths(second, second_path, split_paths); + g_free(first_path); + g_free(second_path); +} + +static void prune_paned_ratios_for_root(KosmosMainWindow *self, JsonObject *root) { + GHashTable *split_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + collect_split_paths(root, "root", split_paths); + + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + g_hash_table_iter_init(&iter, self->paned_ratios); + while (g_hash_table_iter_next(&iter, &key, &value)) { + if (!g_hash_table_contains(split_paths, key)) { + g_hash_table_iter_remove(&iter); + } + } + + g_hash_table_unref(split_paths); +} + +static gboolean update_pane_node_in_place(KosmosMainWindow *self, JsonObject *node, guint64 workspace_id, guint64 active_pane_id) { + const char *type = NULL; + if (!kosmos_json_get_string_member(node, "type", &type)) { + return FALSE; + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = kosmos_json_get_object_member(node, "pane"); + return pane != NULL && kosmos_tabbed_pane_update_from_pane(self, pane, workspace_id, active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + JsonObject *first = kosmos_json_get_object_member(node, "first"); + JsonObject *second = kosmos_json_get_object_member(node, "second"); + return first != NULL && + second != NULL && + update_pane_node_in_place(self, first, workspace_id, active_pane_id) && + update_pane_node_in_place(self, second, workspace_id, active_pane_id); + } + + return FALSE; +} + +gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace) { + guint64 workspace_id = 0; + guint64 active_pane_id = 0; + JsonObject *root = kosmos_json_get_object_member(workspace, "root"); + + if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || + !kosmos_json_get_uint_member(workspace, "activePaneId", &active_pane_id) || + root == NULL) { + return FALSE; + } + + return update_pane_node_in_place(self, root, workspace_id, active_pane_id); +} + +static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer user_data) { + (void)frame_clock; + + PanedRatio *ratio = user_data; + int size = ratio->orientation == GTK_ORIENTATION_HORIZONTAL + ? gtk_widget_get_width(widget) + : gtk_widget_get_height(widget); + + if (size <= 1) { + return G_SOURCE_CONTINUE; + } + + gtk_paned_set_position(GTK_PANED(widget), (int)(size * ratio->ratio)); + return G_SOURCE_REMOVE; +} + +static void set_paned_ratio_after_layout(GtkWidget *paned, GtkOrientation orientation, double ratio) { + PanedRatio *ratio_data = g_new(PanedRatio, 1); + ratio_data->orientation = orientation; + ratio_data->ratio = CLAMP(ratio, 0.05, 0.95); + gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, g_free); +} + +static GtkWidget *create_pane_node( + KosmosMainWindow *self, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path, + GHashTable *reusable_panes +); + +static GtkWidget *create_split_node( + KosmosMainWindow *self, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path, + GHashTable *reusable_panes +) { + const char *axis = NULL; + double ratio = 0.5; + JsonObject *first = kosmos_json_get_object_member(node, "first"); + JsonObject *second = kosmos_json_get_object_member(node, "second"); + if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return kosmos_main_window_create_label("Invalid pane split.", "error"); + } + + kosmos_json_get_double_member(node, "ratio", &ratio); + get_paned_ratio(self, path, &ratio); + + GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 + ? GTK_ORIENTATION_VERTICAL + : GTK_ORIENTATION_HORIZONTAL; + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + GtkWidget *paned = gtk_paned_new(orientation); + gtk_widget_set_hexpand(paned, TRUE); + gtk_widget_set_vexpand(paned, TRUE); + gtk_paned_set_shrink_start_child(GTK_PANED(paned), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); + g_object_set_data_full(G_OBJECT(paned), "pane-path", g_strdup(path), g_free); + gtk_paned_set_start_child(GTK_PANED(paned), create_pane_node(self, first, workspace_id, active_pane_id, first_path, reusable_panes)); + gtk_paned_set_end_child(GTK_PANED(paned), create_pane_node(self, second, workspace_id, active_pane_id, second_path, reusable_panes)); + set_paned_ratio_after_layout(paned, orientation, ratio); + g_free(first_path); + g_free(second_path); + return paned; +} + +static GtkWidget *create_pane_node( + KosmosMainWindow *self, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path, + GHashTable *reusable_panes +) { + const char *type = NULL; + if (!kosmos_json_get_string_member(node, "type", &type)) { + return kosmos_main_window_create_label("Invalid pane node.", "error"); + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = kosmos_json_get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { + return kosmos_main_window_create_label("Invalid pane leaf.", "error"); + } + + GtkWidget *reusable_pane = reusable_panes == NULL ? NULL : g_hash_table_lookup(reusable_panes, &pane_id); + if (reusable_pane != NULL && gtk_widget_get_parent(reusable_pane) == NULL) { + AdwTabView *tab_view = g_object_get_data(G_OBJECT(reusable_pane), "tab-view"); + if (tab_view != NULL && kosmos_tabbed_pane_update_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { + return reusable_pane; + } + } + + return kosmos_tabbed_pane_create(self, pane, workspace_id, pane_id == active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + return create_split_node(self, node, workspace_id, active_pane_id, path, reusable_panes); + } + + return kosmos_main_window_create_label("Unsupported pane node.", "error"); +} + +static gboolean widget_is_pane_leaf(GtkWidget *widget, guint64 pane_id) { + guint64 widget_pane_id = 0; + + return kosmos_main_window_get_uint64_data(G_OBJECT(widget), "pane-id", &widget_pane_id) && widget_pane_id == pane_id; +} + +static GtkWidget *reconcile_pane_node( + KosmosMainWindow *self, + GtkWidget *current, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id, + const char *path +) { + const char *type = NULL; + if (!kosmos_json_get_string_member(node, "type", &type)) { + return kosmos_main_window_create_label("Invalid pane node.", "error"); + } + + if (g_strcmp0(type, "leaf") == 0) { + JsonObject *pane = kosmos_json_get_object_member(node, "pane"); + guint64 pane_id = 0; + if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { + return kosmos_main_window_create_label("Invalid pane leaf.", "error"); + } + + if (current != NULL && widget_is_pane_leaf(current, pane_id)) { + AdwTabView *tab_view = g_object_get_data(G_OBJECT(current), "tab-view"); + if (tab_view != NULL && kosmos_tabbed_pane_update_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { + return current; + } + } + + return kosmos_tabbed_pane_create(self, pane, workspace_id, pane_id == active_pane_id); + } + + if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + double ratio = 0.5; + JsonObject *first = kosmos_json_get_object_member(node, "first"); + JsonObject *second = kosmos_json_get_object_member(node, "second"); + if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + return kosmos_main_window_create_label("Invalid pane split.", "error"); + } + + kosmos_json_get_double_member(node, "ratio", &ratio); + get_paned_ratio(self, path, &ratio); + + GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 + ? GTK_ORIENTATION_VERTICAL + : GTK_ORIENTATION_HORIZONTAL; + char *first_path = g_strconcat(path, "/first", NULL); + char *second_path = g_strconcat(path, "/second", NULL); + + if (!GTK_IS_PANED(current)) { + GtkWidget *created = create_pane_node(self, node, workspace_id, active_pane_id, path, NULL); + g_free(first_path); + g_free(second_path); + return created; + } + + gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); + gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); + g_object_set_data_full(G_OBJECT(current), "pane-path", g_strdup(path), g_free); + + GtkWidget *old_start = gtk_paned_get_start_child(GTK_PANED(current)); + GtkWidget *new_start = reconcile_pane_node(self, old_start, first, workspace_id, active_pane_id, first_path); + if (new_start != old_start) { + gtk_paned_set_start_child(GTK_PANED(current), new_start); + } + + GtkWidget *old_end = gtk_paned_get_end_child(GTK_PANED(current)); + GtkWidget *new_end = reconcile_pane_node(self, old_end, second, workspace_id, active_pane_id, second_path); + if (new_end != old_end) { + gtk_paned_set_end_child(GTK_PANED(current), new_end); + } + + set_paned_ratio_after_layout(current, orientation, ratio); + g_free(first_path); + g_free(second_path); + return current; + } + + return kosmos_main_window_create_label("Unsupported pane node.", "error"); +} + +void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace) { + remember_paned_ratios(self); + + if (workspace == NULL) { + g_hash_table_remove_all(self->paned_ratios); + kosmos_main_window_clear_content_area(self); + return; + } + + guint64 workspace_id = 0; + guint64 active_pane_id = 0; + JsonObject *root = kosmos_json_get_object_member(workspace, "root"); + + if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || + !kosmos_json_get_uint_member(workspace, "activePaneId", &active_pane_id) || + root == NULL) { + kosmos_main_window_set_status(self, "Invalid workspace snapshot."); + return; + } + + prune_paned_ratios_for_root(self, root); + + GtkWidget *current_root = gtk_widget_get_first_child(self->content_area); + if (current_root != NULL) { + g_hash_table_remove_all(self->pane_views); + GtkWidget *pane_tree = reconcile_pane_node(self, current_root, root, workspace_id, active_pane_id, "root"); + if (pane_tree != current_root) { + gtk_box_remove(GTK_BOX(self->content_area), current_root); + gtk_box_append(GTK_BOX(self->content_area), pane_tree); + } + return; + } + + GtkWidget *pane_tree = create_pane_node(self, root, workspace_id, active_pane_id, "root", NULL); + gtk_box_append(GTK_BOX(self->content_area), pane_tree); +} diff --git a/desktop/src/ui/kosmos_tabbed_pane.c b/desktop/src/ui/kosmos_tabbed_pane.c new file mode 100644 index 00000000..a5daeb01 --- /dev/null +++ b/desktop/src/ui/kosmos_tabbed_pane.c @@ -0,0 +1,551 @@ +#include "ui/kosmos_main_window_private.h" + +typedef struct { + guint64 workspace_id; + guint64 pane_id; + guint64 tab_id; +} PendingTabActivation; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; +} TabBarActivation; + +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; +} DeferredTabActivation; + +void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view) { + g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); +} + +static void set_pending_tab_activation(AdwTabView *view, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { + PendingTabActivation *activation = g_new(PendingTabActivation, 1); + activation->workspace_id = workspace_id; + activation->pane_id = pane_id; + activation->tab_id = tab_id; + + g_object_set_data_full(G_OBJECT(view), "pending-tab-activation", activation, g_free); +} + +static void request_tab_activation(KosmosMainWindow *self, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_activate_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to activate tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static void tab_bar_pressed(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)gesture; + (void)n_press; + (void)x; + (void)y; + + TabBarActivation *activation = user_data; + g_object_set_data(G_OBJECT(activation->view), "tab-press-active", GINT_TO_POINTER(1)); +} + +static void deferred_tab_activation_free(DeferredTabActivation *activation) { + g_object_unref(activation->window); + g_object_unref(activation->view); + g_free(activation); +} + +static gboolean finish_pending_tab_activation(gpointer user_data) { + DeferredTabActivation *activation = user_data; + AdwTabView *view = activation->view; + PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); + PendingTabActivation pending_copy = {0}; + + if (gtk_widget_get_root(GTK_WIDGET(view)) == NULL || pending == NULL || adw_tab_view_get_is_transferring_page(view)) { + kosmos_tabbed_pane_clear_pending_activation(view); + return G_SOURCE_REMOVE; + } + + pending_copy = *pending; + kosmos_tabbed_pane_clear_pending_activation(view); + request_tab_activation(activation->window, pending_copy.workspace_id, pending_copy.pane_id, pending_copy.tab_id); + + return G_SOURCE_REMOVE; +} + +static void tab_bar_released(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)gesture; + (void)n_press; + (void)x; + (void)y; + + TabBarActivation *activation = user_data; + AdwTabView *view = activation->view; + PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); + + g_object_set_data(G_OBJECT(view), "tab-press-active", NULL); + if (pending == NULL || adw_tab_view_get_is_transferring_page(view)) { + kosmos_tabbed_pane_clear_pending_activation(view); + return; + } + + DeferredTabActivation *deferred = g_new(DeferredTabActivation, 1); + deferred->window = g_object_ref(activation->window); + deferred->view = g_object_ref(view); + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_pending_tab_activation, deferred, (GDestroyNotify)deferred_tab_activation_free); +} + +static void tab_transfer_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { + (void)pspec; + (void)user_data; + + AdwTabView *view = ADW_TAB_VIEW(object); + if (adw_tab_view_get_is_transferring_page(view)) { + kosmos_tabbed_pane_clear_pending_activation(view); + } +} + +static void tab_detached(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + (void)page; + (void)position; + (void)user_data; + + kosmos_tabbed_pane_clear_pending_activation(view); +} + +static void activate_selected_tab(GObject *object, GParamSpec *pspec, gpointer user_data) { + (void)pspec; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + if (self->applying_server_state) { + return; + } + + AdwTabView *view = ADW_TAB_VIEW(object); + if (adw_tab_view_get_is_transferring_page(view)) { + return; + } + + AdwTabPage *page = adw_tab_view_get_selected_page(view); + + if (page == NULL || !kosmos_main_window_ensure_connected(self)) { + return; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { + return; + } + + if (g_object_get_data(G_OBJECT(view), "tab-press-active") != NULL) { + set_pending_tab_activation(view, workspace_id, pane_id, tab_id); + return; + } + + request_tab_activation(self, workspace_id, pane_id, tab_id); +} + +static gboolean close_tab(AdwTabView *view, AdwTabPage *page, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + + if (self->applying_server_state) { + adw_tab_view_close_page_finish(view, page, TRUE); + return GDK_EVENT_STOP; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || + !kosmos_main_window_ensure_connected(self)) { + adw_tab_view_close_page_finish(view, page, FALSE); + return GDK_EVENT_STOP; + } + + GError *error = NULL; + JsonNode *state = NULL; + gboolean closed = kosmos_ipc_client_close_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); + adw_tab_view_close_page_finish(view, page, closed); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to close tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } + + return GDK_EVENT_STOP; +} + +static void reorder_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + kosmos_tabbed_pane_clear_pending_activation(view); + + if (self->applying_server_state) { + return; + } + + if (adw_tab_view_get_is_transferring_page(view)) { + return; + } + + guint64 workspace_id = 0; + guint64 pane_id = 0; + guint64 tab_id = 0; + if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || + !kosmos_main_window_ensure_connected(self)) { + kosmos_main_window_refresh_workspace_state(self); + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_reorder_tab(self->ipc_client, workspace_id, pane_id, tab_id, (guint)position, &state, NULL, &error); + + if (error != NULL) { + g_clear_error(&error); + kosmos_main_window_refresh_workspace_state(self); + return; + } + + kosmos_main_window_apply_workspace_state(self, state); + if (state != NULL) { + json_node_unref(state); + } +} + +static void open_blank_tab(GtkButton *button, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + + guint64 workspace_id = 0; + guint64 pane_id = 0; + if (!kosmos_main_window_get_uint64_data(G_OBJECT(button), "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(G_OBJECT(button), "pane-id", &pane_id) || + !kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_open_tab( + self->ipc_client, + workspace_id, + pane_id, + "Blank", + KOSMOS_IPC_TAB_KIND_BLANK, + &state, + NULL, + &error + ); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to open tab"); + + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static GtkWidget *create_new_tab_button(guint64 workspace_id, guint64 pane_id) { + GtkWidget *button = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_set_tooltip_text(button, "Open blank tab"); + kosmos_main_window_set_uint64_data(G_OBJECT(button), "workspace-id", workspace_id); + kosmos_main_window_set_uint64_data(G_OBJECT(button), "pane-id", pane_id); + + return button; +} + +static GtkWidget *create_tab_content(const char *kind) { + (void)kind; + + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + return content; +} + +static void configure_tab_page(AdwTabPage *page, guint64 workspace_id, guint64 pane_id, guint64 tab_id, const char *title) { + adw_tab_page_set_title(page, title); + adw_tab_page_set_tooltip(page, title); + kosmos_main_window_set_uint64_data(G_OBJECT(page), "workspace-id", workspace_id); + kosmos_main_window_set_uint64_data(G_OBJECT(page), "pane-id", pane_id); + kosmos_main_window_set_uint64_data(G_OBJECT(page), "tab-id", tab_id); +} + +static void add_tab_bar_activation_controller(KosmosMainWindow *self, GtkWidget *tab_bar, AdwTabView *tab_view) { + GtkGesture *gesture = gtk_gesture_click_new(); + TabBarActivation *activation = g_new(TabBarActivation, 1); + + activation->window = self; + activation->view = tab_view; + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); + g_object_set_data_full(G_OBJECT(gesture), "tab-bar-activation", activation, g_free); + g_signal_connect(gesture, "pressed", G_CALLBACK(tab_bar_pressed), activation); + g_signal_connect(gesture, "released", G_CALLBACK(tab_bar_released), activation); + gtk_widget_add_controller(tab_bar, GTK_EVENT_CONTROLLER(gesture)); +} + +static AdwTabPage *tab_page_for_id(AdwTabView *view, guint64 tab_id) { + int page_count = adw_tab_view_get_n_pages(view); + + for (int index = 0; index < page_count; index++) { + AdwTabPage *page = adw_tab_view_get_nth_page(view, index); + guint64 page_tab_id = 0; + if (kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) && page_tab_id == tab_id) { + return page; + } + } + + return NULL; +} + +static JsonObject *get_tab_snapshot(JsonArray *tabs, guint index) { + JsonNode *tab_node = json_array_get_element(tabs, index); + + if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { + return NULL; + } + + return json_node_get_object(tab_node); +} + +static gboolean tab_view_matches_snapshot_prefix(AdwTabView *view, JsonArray *tabs) { + int page_count = adw_tab_view_get_n_pages(view); + + if (json_array_get_length(tabs) < (guint)page_count) { + return FALSE; + } + + for (int index = 0; index < page_count; index++) { + AdwTabPage *page = adw_tab_view_get_nth_page(view, index); + JsonObject *tab = get_tab_snapshot(tabs, (guint)index); + guint64 page_tab_id = 0; + guint64 snapshot_tab_id = 0; + + if (tab == NULL || + !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) || + !kosmos_json_get_uint_member(tab, "id", &snapshot_tab_id) || + page_tab_id != snapshot_tab_id) { + return FALSE; + } + } + + return TRUE; +} + +static gboolean tab_view_matches_snapshot_exact(AdwTabView *view, JsonArray *tabs) { + return json_array_get_length(tabs) == (guint)adw_tab_view_get_n_pages(view) && + tab_view_matches_snapshot_prefix(view, tabs); +} + +gboolean kosmos_tabbed_pane_update_from_pane_view( + KosmosMainWindow *self, + AdwTabView *view, + JsonObject *pane, + guint64 workspace_id, + guint64 active_pane_id, + gboolean allow_append +) { + guint64 pane_id = 0; + guint64 active_tab_id = 0; + JsonArray *tabs = kosmos_json_get_array_member(pane, "tabs"); + + if (!kosmos_json_get_uint_member(pane, "id", &pane_id) || + !kosmos_json_get_uint_member(pane, "activeTabId", &active_tab_id) || + tabs == NULL) { + return FALSE; + } + + if (allow_append) { + if (!tab_view_matches_snapshot_prefix(view, tabs)) { + return FALSE; + } + } else if (!tab_view_matches_snapshot_exact(view, tabs)) { + return FALSE; + } + + gboolean was_applying = self->applying_server_state; + self->applying_server_state = TRUE; + + AdwTabPage *active_page = NULL; + guint tab_count = json_array_get_length(tabs); + for (guint index = 0; index < tab_count; index++) { + JsonObject *tab = get_tab_snapshot(tabs, index); + guint64 tab_id = 0; + const char *title = NULL; + const char *kind = NULL; + if (tab == NULL || + !kosmos_json_get_uint_member(tab, "id", &tab_id) || + !kosmos_json_get_string_member(tab, "title", &title)) { + continue; + } + kosmos_json_get_string_member(tab, "kind", &kind); + + AdwTabPage *page = tab_page_for_id(view, tab_id); + if (page == NULL) { + if (!allow_append) { + self->applying_server_state = was_applying; + return FALSE; + } + + page = adw_tab_view_append(view, create_tab_content(kind)); + } + + configure_tab_page(page, workspace_id, pane_id, tab_id, title); + if (tab_id == active_tab_id) { + active_page = page; + } + } + + if (active_page != NULL && adw_tab_view_get_selected_page(view) != active_page) { + adw_tab_view_set_selected_page(view, active_page); + } + + (void)active_pane_id; + + GtkWidget *tab_bar = g_object_get_data(G_OBJECT(view), "tab-bar"); + GtkWidget *new_tab_button = g_object_get_data(G_OBJECT(view), "new-tab-button"); + if (tab_bar != NULL && new_tab_button != NULL) { + kosmos_pane_dnd_configure_single_tab( + self, + tab_bar, + new_tab_button, + view, + workspace_id, + pane_id, + (int)tab_count, + active_page + ); + } + + self->applying_server_state = was_applying; + kosmos_main_window_register_pane_view(self, pane_id, view); + return TRUE; +} + +gboolean kosmos_tabbed_pane_update_from_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, guint64 active_pane_id) { + guint64 pane_id = 0; + if (!kosmos_json_get_uint_member(pane, "id", &pane_id)) { + return FALSE; + } + + AdwTabView *view = kosmos_main_window_pane_view_for(self, pane_id); + if (view == NULL) { + return FALSE; + } + + return kosmos_tabbed_pane_update_from_pane_view(self, view, pane, workspace_id, active_pane_id, TRUE); +} + +GtkWidget *kosmos_tabbed_pane_create(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, gboolean is_active_pane) { + guint64 pane_id = 0; + guint64 active_tab_id = 0; + JsonArray *tabs = kosmos_json_get_array_member(pane, "tabs"); + + if (!kosmos_json_get_uint_member(pane, "id", &pane_id) || + !kosmos_json_get_uint_member(pane, "activeTabId", &active_tab_id) || + tabs == NULL) { + return kosmos_main_window_create_label("Invalid pane snapshot.", "error"); + } + + GtkWidget *container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(container, TRUE); + gtk_widget_set_vexpand(container, TRUE); + gtk_widget_set_size_request(container, KOSMOS_MIN_PANE_WIDTH, KOSMOS_MIN_PANE_HEIGHT); + + (void)is_active_pane; + + AdwTabView *tab_view = adw_tab_view_new(); + AdwTabBar *tab_bar = adw_tab_bar_new(); + GtkWidget *new_tab_button = create_new_tab_button(workspace_id, pane_id); + adw_tab_bar_set_view(tab_bar, tab_view); + adw_tab_bar_set_end_action_widget(tab_bar, new_tab_button); + adw_tab_bar_set_autohide(tab_bar, FALSE); + adw_tab_bar_set_expand_tabs(tab_bar, FALSE); + adw_tab_view_set_shortcuts( + tab_view, + ADW_TAB_VIEW_SHORTCUT_CONTROL_TAB | + ADW_TAB_VIEW_SHORTCUT_CONTROL_SHIFT_TAB | + ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_UP | + ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_DOWN | + ADW_TAB_VIEW_SHORTCUT_ALT_DIGITS | + ADW_TAB_VIEW_SHORTCUT_ALT_ZERO + ); + gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); + + guint tab_count = json_array_get_length(tabs); + AdwTabPage *active_page = NULL; + for (guint index = 0; index < tab_count; index++) { + JsonNode *tab_node = json_array_get_element(tabs, index); + if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { + continue; + } + + JsonObject *tab = json_node_get_object(tab_node); + guint64 tab_id = 0; + const char *title = NULL; + const char *kind = NULL; + if (!kosmos_json_get_uint_member(tab, "id", &tab_id) || !kosmos_json_get_string_member(tab, "title", &title)) { + continue; + } + kosmos_json_get_string_member(tab, "kind", &kind); + + GtkWidget *content = create_tab_content(kind); + AdwTabPage *page = adw_tab_view_append(tab_view, content); + configure_tab_page(page, workspace_id, pane_id, tab_id, title); + + if (tab_id == active_tab_id) { + active_page = page; + } + } + + if (active_page != NULL) { + adw_tab_view_set_selected_page(tab_view, active_page); + } + + kosmos_main_window_register_pane_view(self, pane_id, tab_view); + kosmos_main_window_set_uint64_data(G_OBJECT(container), "pane-id", pane_id); + g_object_set_data(G_OBJECT(container), "tab-view", tab_view); + g_object_set_data(G_OBJECT(tab_view), "pane-container", container); + g_object_set_data(G_OBJECT(tab_view), "tab-bar", GTK_WIDGET(tab_bar)); + g_object_set_data(G_OBJECT(tab_view), "new-tab-button", new_tab_button); + + add_tab_bar_activation_controller(self, GTK_WIDGET(tab_bar), tab_view); + kosmos_pane_dnd_configure_single_tab( + self, + GTK_WIDGET(tab_bar), + new_tab_button, + tab_view, + workspace_id, + pane_id, + (int)tab_count, + active_page + ); + + gtk_box_append(GTK_BOX(container), GTK_WIDGET(tab_bar)); + gtk_box_append(GTK_BOX(container), kosmos_pane_dnd_create_split_overlay(self, tab_view, pane_id)); + g_signal_connect(tab_view, "notify::selected-page", G_CALLBACK(activate_selected_tab), self); + g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), NULL); + g_signal_connect(tab_view, "close-page", G_CALLBACK(close_tab), self); + g_signal_connect(tab_view, "create-window", G_CALLBACK(kosmos_pane_dnd_create_split_sink_for_detached_tab), self); + g_signal_connect(tab_view, "page-detached", G_CALLBACK(tab_detached), NULL); + g_signal_connect(tab_view, "page-reordered", G_CALLBACK(reorder_tab), self); + g_signal_connect(new_tab_button, "clicked", G_CALLBACK(open_blank_tab), self); + + return container; +} diff --git a/desktop/src/ui/kosmos_workspace_switcher.c b/desktop/src/ui/kosmos_workspace_switcher.c new file mode 100644 index 00000000..88037aa1 --- /dev/null +++ b/desktop/src/ui/kosmos_workspace_switcher.c @@ -0,0 +1,334 @@ +#include "ui/kosmos_main_window_private.h" + +void kosmos_workspace_switcher_clear(KosmosMainWindow *self) { + GtkWidget *child = gtk_widget_get_first_child(self->workspace_switcher); + + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(self->workspace_switcher), child); + child = next; + } + + g_hash_table_remove_all(self->workspace_buttons); + self->add_workspace_button = NULL; +} + +static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_workspace_id) { + JsonNode *node = json_object_get_member(result, "activeWorkspaceId"); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { + return FALSE; + } + + gint64 signed_value = json_node_get_int(node); + if (signed_value < 0) { + return FALSE; + } + + *active_workspace_id = (guint64)signed_value; + return TRUE; +} + +static void activate_workspace(GtkWidget *button, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + guint64 *workspace_id = g_object_get_data(G_OBJECT(button), "workspace-id"); + + if (workspace_id == NULL || !kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + if (!kosmos_ipc_client_activate_workspace(self->ipc_client, *workspace_id, &state, NULL, &error)) { + kosmos_main_window_set_error_status(self, "Failed to activate workspace", error); + g_clear_error(&error); + return; + } + + kosmos_main_window_apply_workspace_state(self, state); + json_node_unref(state); +} + +static void open_workspace_path(KosmosMainWindow *self, const char *path) { + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + if (!kosmos_ipc_client_open_workspace(self->ipc_client, path, &state, NULL, &error)) { + kosmos_main_window_set_error_status(self, "Failed to open workspace", error); + g_clear_error(&error); + return; + } + + kosmos_main_window_apply_workspace_state(self, state); + json_node_unref(state); +} + +static void open_workspace_selected(GObject *source_object, GAsyncResult *result, gpointer user_data) { + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + GError *error = NULL; + GFile *folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source_object), result, &error); + + if (folder == NULL) { + if (!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { + kosmos_main_window_set_error_status(self, "Failed to select workspace", error); + } + + g_clear_error(&error); + g_object_unref(self); + return; + } + + char *path = g_file_get_path(folder); + if (path == NULL) { + kosmos_main_window_set_status(self, "Selected workspace is not a local directory."); + } else { + open_workspace_path(self, path); + } + + g_free(path); + g_object_unref(folder); + g_object_unref(self); +} + +static void open_workspace_picker(GtkButton *button, gpointer user_data) { + (void)button; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + GtkFileDialog *dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, "Open Workspace"); + gtk_file_dialog_select_folder(dialog, GTK_WINDOW(self), NULL, open_workspace_selected, g_object_ref(self)); + g_object_unref(dialog); +} + +GtkWidget *kosmos_workspace_switcher_ensure_add_button(KosmosMainWindow *self, gboolean sensitive) { + if (self->add_workspace_button == NULL) { + self->add_workspace_button = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_set_tooltip_text(self->add_workspace_button, "Open workspace"); + gtk_box_append(GTK_BOX(self->workspace_switcher), self->add_workspace_button); + g_signal_connect(self->add_workspace_button, "clicked", G_CALLBACK(open_workspace_picker), self); + } + + gtk_widget_set_sensitive(self->add_workspace_button, sensitive); + return self->add_workspace_button; +} + +static void split_workspace_name(const char *name, char **initial, char **suffix) { + if (name == NULL || name[0] == '\0') { + *initial = g_strdup("?"); + *suffix = g_strdup(""); + return; + } + + const char *suffix_start = g_utf8_next_char(name); + *initial = g_strndup(name, suffix_start - name); + *suffix = g_strdup(suffix_start); +} + +static void update_workspace_button_name(GtkWidget *button, const char *name) { + GtkWidget *initial_label = g_object_get_data(G_OBJECT(button), "workspace-initial-label"); + GtkWidget *suffix_label = g_object_get_data(G_OBJECT(button), "workspace-suffix-label"); + char *initial = NULL; + char *suffix = NULL; + + split_workspace_name(name, &initial, &suffix); + gtk_label_set_text(GTK_LABEL(initial_label), initial); + gtk_label_set_text(GTK_LABEL(suffix_label), suffix); + + g_free(initial); + g_free(suffix); +} + +static void set_workspace_button_active(GtkWidget *button, gboolean active) { + GtkWidget *revealer = g_object_get_data(G_OBJECT(button), "workspace-suffix-revealer"); + + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); + gtk_revealer_set_reveal_child(GTK_REVEALER(revealer), active); +} + +static GtkWidget *create_workspace_button(guint64 workspace_id, const char *name) { + GtkWidget *button = gtk_toggle_button_new(); + GtkWidget *label_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + GtkWidget *initial_label = gtk_label_new(NULL); + GtkWidget *suffix_label = gtk_label_new(NULL); + GtkWidget *revealer = gtk_revealer_new(); + + gtk_widget_set_halign(label_box, GTK_ALIGN_CENTER); + gtk_widget_set_valign(label_box, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(initial_label), 0.5f); + + gtk_revealer_set_transition_type(GTK_REVEALER(revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT); + gtk_revealer_set_transition_duration(GTK_REVEALER(revealer), 180); + gtk_revealer_set_child(GTK_REVEALER(revealer), suffix_label); + + gtk_box_append(GTK_BOX(label_box), initial_label); + gtk_box_append(GTK_BOX(label_box), revealer); + gtk_button_set_child(GTK_BUTTON(button), label_box); + + g_object_set_data(G_OBJECT(button), "workspace-initial-label", initial_label); + g_object_set_data(G_OBJECT(button), "workspace-suffix-label", suffix_label); + g_object_set_data(G_OBJECT(button), "workspace-suffix-revealer", revealer); + + guint64 *button_workspace_id = g_new(guint64, 1); + *button_workspace_id = workspace_id; + g_object_set_data_full(G_OBJECT(button), "workspace-id", button_workspace_id, g_free); + + update_workspace_button_name(button, name); + + return button; +} + +static GtkWidget *workspace_button_for(KosmosMainWindow *self, guint64 workspace_id, const char *name) { + GtkWidget *button = g_hash_table_lookup(self->workspace_buttons, &workspace_id); + + if (button != NULL) { + update_workspace_button_name(button, name); + return button; + } + + button = create_workspace_button(workspace_id, name); + guint64 *key = g_new(guint64, 1); + *key = workspace_id; + g_hash_table_insert(self->workspace_buttons, key, button); + gtk_box_append(GTK_BOX(self->workspace_switcher), button); + g_signal_connect(button, "clicked", G_CALLBACK(activate_workspace), self); + + return button; +} + +static void remove_stale_workspace_buttons(KosmosMainWindow *self, GHashTable *seen_workspace_ids) { + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + + g_hash_table_iter_init(&iter, self->workspace_buttons); + while (g_hash_table_iter_next(&iter, &key, &value)) { + if (!g_hash_table_contains(seen_workspace_ids, key)) { + gtk_box_remove(GTK_BOX(self->workspace_switcher), GTK_WIDGET(value)); + g_hash_table_iter_remove(&iter); + } + } +} + +static GtkWidget *update_workspace_button( + KosmosMainWindow *self, + JsonObject *workspace, + guint64 active_workspace_id, + gboolean has_active_workspace +) { + guint64 workspace_id = 0; + const char *name = NULL; + + if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || + !kosmos_json_get_string_member(workspace, "name", &name)) { + return NULL; + } + + GtkWidget *button = workspace_button_for(self, workspace_id, name); + set_workspace_button_active(button, has_active_workspace && workspace_id == active_workspace_id); + + return button; +} + +static JsonObject *find_active_workspace(JsonArray *workspaces, guint64 active_workspace_id, gboolean has_active_workspace) { + if (!has_active_workspace) { + return NULL; + } + + guint workspace_count = json_array_get_length(workspaces); + for (guint index = 0; index < workspace_count; index++) { + JsonNode *workspace_node = json_array_get_element(workspaces, index); + if (workspace_node == NULL || !JSON_NODE_HOLDS_OBJECT(workspace_node)) { + continue; + } + + JsonObject *workspace = json_node_get_object(workspace_node); + guint64 workspace_id = 0; + if (kosmos_json_get_uint_member(workspace, "id", &workspace_id) && workspace_id == active_workspace_id) { + return workspace; + } + } + + return NULL; +} + +void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { + if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { + kosmos_main_window_set_status(self, "Workspace state is unavailable."); + return; + } + + JsonObject *result = json_node_get_object(state); + JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); + if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { + kosmos_main_window_set_status(self, "Workspace state is missing workspaces."); + return; + } + + guint64 active_workspace_id = 0; + gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); + JsonArray *workspaces = json_node_get_array(workspaces_node); + guint workspace_count = json_array_get_length(workspaces); + JsonObject *active_workspace = find_active_workspace(workspaces, active_workspace_id, has_active_workspace); + GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); + GtkWidget *previous_button = NULL; + + for (guint index = 0; index < workspace_count; index++) { + JsonNode *workspace_node = json_array_get_element(workspaces, index); + if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { + JsonObject *workspace = json_node_get_object(workspace_node); + guint64 workspace_id = 0; + + if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id)) { + continue; + } + + guint64 *seen_id = g_new(guint64, 1); + *seen_id = workspace_id; + g_hash_table_add(seen_workspace_ids, seen_id); + + GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); + if (button != NULL) { + gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); + previous_button = button; + } + } + } + + remove_stale_workspace_buttons(self, seen_workspace_ids); + gtk_box_reorder_child_after( + GTK_BOX(self->workspace_switcher), + kosmos_workspace_switcher_ensure_add_button(self, TRUE), + previous_button + ); + g_hash_table_unref(seen_workspace_ids); + + (void)workspace_count; + if (active_workspace != NULL) { + char *layout_signature = kosmos_pane_layout_create_signature(active_workspace); + if (layout_signature != NULL && + self->layout_signature != NULL && + g_strcmp0(layout_signature, self->layout_signature) == 0 && + kosmos_pane_layout_update_active_workspace_in_place(self, active_workspace)) { + g_free(layout_signature); + return; + } + + kosmos_pane_layout_render_active_workspace(self, active_workspace); + g_clear_pointer(&self->layout_signature, g_free); + self->layout_signature = layout_signature; + return; + } + + kosmos_pane_layout_render_active_workspace(self, active_workspace); +} + +GtkWidget *kosmos_workspace_switcher_create(KosmosMainWindow *self) { + self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_add_css_class(self->workspace_switcher, "linked"); + gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); + kosmos_workspace_switcher_ensure_add_button(self, FALSE); + + return self->workspace_switcher; +} From 5db5be125119a56e83b77687db8aeb024737db39 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 01:04:39 -0300 Subject: [PATCH 10/98] refactor: own pane split ratios in core --- core/src/state.rs | 62 +++- core/src/tree/mod.rs | 2 +- core/src/tree/panes.rs | 105 +++++- core/src/tree/workspaces.rs | 60 ++- desktop/src/ipc/kosmos_ipc_client.h | 10 + desktop/src/ipc/kosmos_ipc_pane.c | 28 ++ desktop/src/ipc/kosmos_ipc_protocol.h | 1 + desktop/src/ui/kosmos_main_window.c | 11 +- desktop/src/ui/kosmos_main_window_private.h | 9 +- desktop/src/ui/kosmos_pane_layout.c | 383 +++++++++++++++----- desktop/src/ui/kosmos_workspace_switcher.c | 31 ++ server/src/ipc/messages/pane.rs | 21 +- server/src/ipc/router/pane.rs | 22 +- 13 files changed, 614 insertions(+), 131 deletions(-) diff --git a/core/src/state.rs b/core/src/state.rs index 376e77d6..710b0e7c 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; use crate::tree::{ - Pane, PaneId, SplitAxis, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, + Pane, PaneId, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, + WorkspaceList, }; #[derive(Debug)] @@ -9,6 +10,7 @@ pub struct State { workspaces: WorkspaceList, next_workspace_id: u64, next_pane_id: u64, + next_split_id: u64, next_tab_id: u64, } @@ -52,13 +54,21 @@ impl State { let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { return false; }; + let split_id = self.next_split_id(); let new_pane = self.blank_pane(); let Some(workspace) = self.workspace_mut(workspace_id) else { return false; }; let pane_id = pane_id.unwrap_or_else(|| workspace.active_pane_id()); - workspace.split_pane_with_new_pane_first(pane_id, axis, new_pane, 0.5, new_pane_first) + workspace.split_pane_with_new_pane_first( + split_id, + pane_id, + axis, + new_pane, + 0.5, + new_pane_first, + ) } pub fn activate_pane(&mut self, workspace_id: Option, pane_id: PaneId) -> bool { @@ -83,11 +93,12 @@ impl State { let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { return false; }; + let split_id = self.next_split_id(); let Some(workspace) = self.workspace_mut(workspace_id) else { return false; }; - workspace.move_pane_to_split(pane_id, target_pane_id, axis, 0.5, new_pane_first) + workspace.move_pane_to_split(split_id, pane_id, target_pane_id, axis, 0.5, new_pane_first) } pub fn open_tab( @@ -159,6 +170,7 @@ impl State { let fallback_tab = (source_pane.tabs().len() == 1).then(|| self.next_tab("Blank", TabKind::Blank)); let new_pane_id = self.next_pane_id(); + let split_id = self.next_split_id(); let Some(workspace) = self.workspace_mut(workspace_id) else { return false; @@ -176,6 +188,7 @@ impl State { let new_pane = Pane::new(new_pane_id, tab); workspace.split_pane_with_new_pane_first( + split_id, target_pane_id, axis, new_pane, @@ -214,6 +227,22 @@ impl State { workspace.reorder_tab_in_pane(pane_id, tab_id, target_index) } + pub fn resize_split( + &mut self, + workspace_id: Option, + split_id: SplitPaneId, + ratio: f32, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.resize_split(split_id, ratio) + } + fn workspace_mut(&mut self, workspace_id: WorkspaceId) -> Option<&mut Workspace> { self.workspaces.workspace_mut(workspace_id) } @@ -234,6 +263,12 @@ impl State { pane_id } + fn next_split_id(&mut self) -> SplitPaneId { + let split_id = SplitPaneId::new(self.next_split_id); + self.next_split_id += 1; + split_id + } + fn next_tab_id(&mut self) -> TabId { let tab_id = TabId::new(self.next_tab_id); self.next_tab_id += 1; @@ -258,6 +293,7 @@ impl Default for State { workspaces: WorkspaceList::new(), next_workspace_id: 1, next_pane_id: 1, + next_split_id: 1, next_tab_id: 1, } } @@ -388,4 +424,24 @@ mod tests { assert!(workspace.root().contains_pane(PaneId::new(2))); assert_eq!(workspace.active_pane_id(), PaneId::new(1)); } + + #[test] + fn resizing_split_updates_server_owned_ratio() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + assert!(state.split_pane(None, None, SplitAxis::Horizontal, false)); + + assert!(state.resize_split(None, SplitPaneId::new(1), 0.7)); + assert!(!state.resize_split(None, SplitPaneId::new(1), 1.0)); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let crate::tree::PaneNode::Split(split) = workspace.root() else { + panic!("workspace root should be split"); + }; + + assert_eq!(split.ratio(), 0.7); + } } diff --git a/core/src/tree/mod.rs b/core/src/tree/mod.rs index d6cf1344..a671c3c0 100644 --- a/core/src/tree/mod.rs +++ b/core/src/tree/mod.rs @@ -2,6 +2,6 @@ mod panes; mod tabs; mod workspaces; -pub use panes::{Pane, PaneId, PaneNode, SplitAxis, SplitPane}; +pub use panes::{Pane, PaneId, PaneNode, SplitAxis, SplitPane, SplitPaneId}; pub use tabs::{Tab, TabId, TabKind}; pub use workspaces::{Workspace, WorkspaceId, WorkspaceList}; diff --git a/core/src/tree/panes.rs b/core/src/tree/panes.rs index 19bc9eea..07bc4b60 100644 --- a/core/src/tree/panes.rs +++ b/core/src/tree/panes.rs @@ -13,6 +13,19 @@ impl PaneId { } } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct SplitPaneId(u64); + +impl SplitPaneId { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn value(self) -> u64 { + self.0 + } +} + #[derive(Clone, Debug, PartialEq)] pub enum PaneNode { Leaf(Pane), @@ -24,8 +37,14 @@ impl PaneNode { Self::Leaf(pane) } - pub fn split(axis: SplitAxis, ratio: f32, first: PaneNode, second: PaneNode) -> Self { - Self::Split(SplitPane::new(axis, ratio, first, second)) + pub fn split( + id: SplitPaneId, + axis: SplitAxis, + ratio: f32, + first: PaneNode, + second: PaneNode, + ) -> Self { + Self::Split(SplitPane::new(id, axis, ratio, first, second)) } pub fn find_pane(&self, pane_id: PaneId) -> Option<&Pane> { @@ -73,16 +92,18 @@ impl PaneNode { pub fn split_pane( &mut self, + split_id: SplitPaneId, pane_id: PaneId, axis: SplitAxis, new_pane: Pane, ratio: f32, ) -> bool { - self.split_pane_with_new_pane_first(pane_id, axis, new_pane, ratio, false) + self.split_pane_with_new_pane_first(split_id, pane_id, axis, new_pane, ratio, false) } pub fn split_pane_with_new_pane_first( &mut self, + split_id: SplitPaneId, pane_id: PaneId, axis: SplitAxis, new_pane: Pane, @@ -100,9 +121,21 @@ impl PaneNode { }; *self = if new_pane_first { - Self::split(axis, ratio, Self::Leaf(new_pane), Self::Leaf(existing_pane)) + Self::split( + split_id, + axis, + ratio, + Self::Leaf(new_pane), + Self::Leaf(existing_pane), + ) } else { - Self::split(axis, ratio, Self::Leaf(existing_pane), Self::Leaf(new_pane)) + Self::split( + split_id, + axis, + ratio, + Self::Leaf(existing_pane), + Self::Leaf(new_pane), + ) }; true @@ -111,6 +144,7 @@ impl PaneNode { Self::Split(split) => { if split.first().contains_pane(pane_id) { split.first_mut().split_pane_with_new_pane_first( + split_id, pane_id, axis, new_pane, @@ -119,6 +153,7 @@ impl PaneNode { ) } else { split.second_mut().split_pane_with_new_pane_first( + split_id, pane_id, axis, new_pane, @@ -137,10 +172,25 @@ impl PaneNode { Self::Split(split) => remove_pane_from_split(split, pane_id), } } + + pub fn set_split_ratio(&mut self, split_id: SplitPaneId, ratio: f32) -> bool { + match self { + Self::Leaf(_) => false, + Self::Split(split) if split.id() == split_id => split.try_set_ratio(ratio), + Self::Split(split) => { + if split.first_mut().set_split_ratio(split_id, ratio) { + true + } else { + split.second_mut().set_split_ratio(split_id, ratio) + } + } + } + } } fn remove_pane_from_split(split: SplitPane, pane_id: PaneId) -> (Option, Option) { let SplitPane { + id, axis, ratio, first, @@ -150,7 +200,7 @@ fn remove_pane_from_split(split: SplitPane, pane_id: PaneId) -> (Option PaneNode::split(axis, ratio, first_node, *second), + Some(first_node) => PaneNode::split(id, axis, ratio, first_node, *second), None => *second, }; @@ -161,7 +211,7 @@ fn remove_pane_from_split(split: SplitPane, pane_id: PaneId) -> (Option PaneNode::split(axis, ratio, first_node, second_node), + Some(second_node) => PaneNode::split(id, axis, ratio, first_node, second_node), None => first_node, }; @@ -171,13 +221,14 @@ fn remove_pane_from_split(split: SplitPane, pane_id: PaneId) -> (Option, @@ -185,10 +236,17 @@ pub struct SplitPane { } impl SplitPane { - pub fn new(axis: SplitAxis, ratio: f32, first: PaneNode, second: PaneNode) -> Self { + pub fn new( + id: SplitPaneId, + axis: SplitAxis, + ratio: f32, + first: PaneNode, + second: PaneNode, + ) -> Self { validate_split_ratio(ratio); Self { + id, axis, ratio, first: Box::new(first), @@ -196,6 +254,10 @@ impl SplitPane { } } + pub fn id(&self) -> SplitPaneId { + self.id + } + pub fn axis(&self) -> SplitAxis { self.axis } @@ -210,6 +272,15 @@ impl SplitPane { self.ratio = ratio; } + pub fn try_set_ratio(&mut self, ratio: f32) -> bool { + if !is_valid_split_ratio(ratio) { + return false; + } + + self.ratio = ratio; + true + } + pub fn first(&self) -> &PaneNode { &self.first } @@ -347,11 +418,15 @@ impl Pane { fn validate_split_ratio(ratio: f32) { assert!( - ratio.is_finite() && ratio > 0.0 && ratio < 1.0, + is_valid_split_ratio(ratio), "split ratio must be a finite value between 0.0 and 1.0" ); } +fn is_valid_split_ratio(ratio: f32) -> bool { + ratio.is_finite() && ratio > 0.0 && ratio < 1.0 +} + #[cfg(test)] mod tests { use super::*; @@ -399,7 +474,13 @@ mod tests { ); let mut root = PaneNode::leaf(first_pane); - assert!(root.split_pane(PaneId::new(1), SplitAxis::Horizontal, second_pane, 0.5)); + assert!(root.split_pane( + SplitPaneId::new(1), + PaneId::new(1), + SplitAxis::Horizontal, + second_pane, + 0.5, + )); assert!(root.contains_pane(PaneId::new(1))); assert!(root.contains_pane(PaneId::new(2))); @@ -418,6 +499,7 @@ mod tests { let mut root = PaneNode::leaf(first_pane); assert!(root.split_pane_with_new_pane_first( + SplitPaneId::new(1), PaneId::new(1), SplitAxis::Horizontal, second_pane, @@ -444,6 +526,7 @@ mod tests { Tab::new(TabId::new(2), "Second", TabKind::Blank), ); let root = PaneNode::split( + SplitPaneId::new(1), SplitAxis::Horizontal, 0.5, PaneNode::leaf(first_pane), diff --git a/core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs index 87dc10d6..3a6147f7 100644 --- a/core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use super::panes::{Pane, PaneId, PaneNode, SplitAxis}; +use super::panes::{Pane, PaneId, PaneNode, SplitAxis, SplitPaneId}; use super::tabs::{Tab, TabId}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -104,12 +104,18 @@ impl Workspace { } } - pub fn split_active_pane(&mut self, axis: SplitAxis, new_pane: Pane, ratio: f32) -> bool { + pub fn split_active_pane( + &mut self, + split_id: SplitPaneId, + axis: SplitAxis, + new_pane: Pane, + ratio: f32, + ) -> bool { let new_pane_id = new_pane.id(); if self .root - .split_pane(self.active_pane, axis, new_pane, ratio) + .split_pane(split_id, self.active_pane, axis, new_pane, ratio) { self.active_pane = new_pane_id; true @@ -196,6 +202,7 @@ impl Workspace { pub fn split_pane_with_new_pane_first( &mut self, + split_id: SplitPaneId, pane_id: PaneId, axis: SplitAxis, new_pane: Pane, @@ -204,10 +211,14 @@ impl Workspace { ) -> bool { let new_pane_id = new_pane.id(); - if self - .root - .split_pane_with_new_pane_first(pane_id, axis, new_pane, ratio, new_pane_first) - { + if self.root.split_pane_with_new_pane_first( + split_id, + pane_id, + axis, + new_pane, + ratio, + new_pane_first, + ) { self.active_pane = new_pane_id; true } else { @@ -217,6 +228,7 @@ impl Workspace { pub fn move_pane_to_split( &mut self, + split_id: SplitPaneId, pane_id: PaneId, target_pane_id: PaneId, axis: SplitAxis, @@ -236,7 +248,14 @@ impl Workspace { }; let pane_id = pane.id(); - if !root.split_pane_with_new_pane_first(target_pane_id, axis, pane, ratio, new_pane_first) { + if !root.split_pane_with_new_pane_first( + split_id, + target_pane_id, + axis, + pane, + ratio, + new_pane_first, + ) { return false; } @@ -245,6 +264,10 @@ impl Workspace { true } + pub fn resize_split(&mut self, split_id: SplitPaneId, ratio: f32) -> bool { + self.root.set_split_ratio(split_id, ratio) + } + fn remove_empty_pane(&mut self, pane_id: PaneId, fallback_pane: Pane) { let previous_active_pane = self.active_pane; let fallback_pane_id = fallback_pane.id(); @@ -426,7 +449,12 @@ mod tests { fn splitting_active_pane_focuses_new_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - assert!(workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5)); + assert!(workspace.split_active_pane( + SplitPaneId::new(1), + SplitAxis::Vertical, + pane(2, 2), + 0.5, + )); assert_eq!(workspace.active_pane_id(), PaneId::new(2)); assert!(workspace.root().contains_pane(PaneId::new(1))); @@ -436,7 +464,7 @@ mod tests { #[test] fn closing_last_tab_removes_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + workspace.split_active_pane(SplitPaneId::new(1), SplitAxis::Vertical, pane(2, 2), 0.5); let removed_tab = workspace.close_tab(PaneId::new(2), TabId::new(2), pane(3, 3)); @@ -449,7 +477,7 @@ mod tests { #[test] fn failed_tab_activation_keeps_active_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + workspace.split_active_pane(SplitPaneId::new(1), SplitAxis::Vertical, pane(2, 2), 0.5); workspace.activate_pane(PaneId::new(1)); assert!(!workspace.activate_tab(PaneId::new(2), TabId::new(999))); @@ -460,7 +488,7 @@ mod tests { #[test] fn failed_tab_reorder_keeps_active_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - workspace.split_active_pane(SplitAxis::Vertical, pane(2, 2), 0.5); + workspace.split_active_pane(SplitPaneId::new(1), SplitAxis::Vertical, pane(2, 2), 0.5); workspace.activate_pane(PaneId::new(1)); assert!(!workspace.reorder_tab_in_pane(PaneId::new(2), TabId::new(999), 0,)); @@ -471,8 +499,9 @@ mod tests { #[test] fn closing_inactive_empty_pane_keeps_active_pane_when_it_still_exists() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - workspace.split_active_pane(SplitAxis::Horizontal, pane(2, 2), 0.5); + workspace.split_active_pane(SplitPaneId::new(1), SplitAxis::Horizontal, pane(2, 2), 0.5); workspace.split_pane_with_new_pane_first( + SplitPaneId::new(2), PaneId::new(1), SplitAxis::Vertical, pane(3, 3), @@ -491,8 +520,9 @@ mod tests { #[test] fn moving_pane_splits_target_with_existing_pane() { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); - workspace.split_active_pane(SplitAxis::Horizontal, pane(2, 2), 0.5); + workspace.split_active_pane(SplitPaneId::new(1), SplitAxis::Horizontal, pane(2, 2), 0.5); workspace.split_pane_with_new_pane_first( + SplitPaneId::new(2), PaneId::new(1), SplitAxis::Vertical, pane(3, 3), @@ -501,6 +531,7 @@ mod tests { ); assert!(workspace.move_pane_to_split( + SplitPaneId::new(3), PaneId::new(3), PaneId::new(2), SplitAxis::Horizontal, @@ -530,6 +561,7 @@ mod tests { let mut workspace = Workspace::new(WorkspaceId::new(1), "/workspaces/main", pane(1, 1)); assert!(workspace.move_pane_to_split( + SplitPaneId::new(1), PaneId::new(1), PaneId::new(1), SplitAxis::Horizontal, diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h index e26d91f9..b74dfad6 100644 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -96,6 +96,16 @@ gboolean kosmos_ipc_client_move_pane( GError **error ); +gboolean kosmos_ipc_client_resize_pane_split( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 split_id, + double ratio, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + gboolean kosmos_ipc_client_open_tab( KosmosIpcClient *self, guint64 workspace_id, diff --git a/desktop/src/ipc/kosmos_ipc_pane.c b/desktop/src/ipc/kosmos_ipc_pane.c index 81a1b4f3..35d0bc2d 100644 --- a/desktop/src/ipc/kosmos_ipc_pane.c +++ b/desktop/src/ipc/kosmos_ipc_pane.c @@ -87,3 +87,31 @@ gboolean kosmos_ipc_client_move_pane( json_object_unref(params); return requested; } + +gboolean kosmos_ipc_client_resize_pane_split( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 split_id, + double ratio, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "splitId", (gint64)split_id); + json_object_set_double_member(params, "ratio", ratio); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_PANE, + KOSMOS_IPC_ACTION_RESIZE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h index 11c5e133..e74c91e9 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -32,6 +32,7 @@ typedef enum { #define KOSMOS_IPC_ACTION_SPLIT "split" #define KOSMOS_IPC_ACTION_REORDER "reorder" #define KOSMOS_IPC_ACTION_MOVE "move" +#define KOSMOS_IPC_ACTION_RESIZE "resize" const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain); const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis); diff --git a/desktop/src/ui/kosmos_main_window.c b/desktop/src/ui/kosmos_main_window.c index 56086756..862cf080 100644 --- a/desktop/src/ui/kosmos_main_window.c +++ b/desktop/src/ui/kosmos_main_window.c @@ -22,6 +22,15 @@ void kosmos_main_window_clear_content_area(KosmosMainWindow *self) { } g_clear_pointer(&self->layout_signature, g_free); + if (self->staged_content_area != NULL) { + gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); + self->staged_content_area = NULL; + } + self->pending_layout_applies = 0; + self->hiding_layout_apply = FALSE; + self->has_rendered_workspace = FALSE; + gtk_widget_set_opacity(self->content_area, 1.0); + GtkWidget *child = gtk_widget_get_first_child(self->content_area); while (child != NULL) { @@ -172,7 +181,6 @@ static GtkWidget *create_header(KosmosMainWindow *self) { static void kosmos_main_window_init(KosmosMainWindow *self) { self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - self->paned_ratios = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); self->pane_views = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); kosmos_pane_dnd_install_css(GTK_WIDGET(self)); @@ -200,7 +208,6 @@ static void kosmos_main_window_finalize(GObject *object) { kosmos_pane_dnd_clear_detached_tab_transfer(self); g_clear_object(&self->ipc_client); g_clear_pointer(&self->workspace_buttons, g_hash_table_unref); - g_clear_pointer(&self->paned_ratios, g_hash_table_unref); g_clear_pointer(&self->pane_views, g_hash_table_unref); g_clear_pointer(&self->layout_signature, g_free); diff --git a/desktop/src/ui/kosmos_main_window_private.h b/desktop/src/ui/kosmos_main_window_private.h index 5de9174e..ec275053 100644 --- a/desktop/src/ui/kosmos_main_window_private.h +++ b/desktop/src/ui/kosmos_main_window_private.h @@ -15,15 +15,20 @@ struct _KosmosMainWindow { GtkWidget *add_workspace_button; GtkWidget *content_overlay; GtkWidget *content_area; + GtkWidget *staged_content_area; GHashTable *workspace_buttons; - GHashTable *paned_ratios; GHashTable *pane_views; char *layout_signature; + guint64 rendered_workspace_id; + guint pending_layout_applies; + guint layout_apply_serial; AdwTabView *detached_tab_sink; GtkWindow *detached_tab_window; gboolean splitting_detached_tab; gboolean suppress_tab_detach; gboolean applying_server_state; + gboolean has_rendered_workspace; + gboolean hiding_layout_apply; }; typedef struct { @@ -63,6 +68,8 @@ GtkWidget *kosmos_workspace_switcher_ensure_add_button(KosmosMainWindow *self, g void kosmos_workspace_switcher_clear(KosmosMainWindow *self); char *kosmos_pane_layout_create_signature(JsonObject *workspace); +void kosmos_pane_layout_begin_hidden_apply(KosmosMainWindow *self); +void kosmos_pane_layout_finish_hidden_apply(KosmosMainWindow *self); gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace); void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace); diff --git a/desktop/src/ui/kosmos_pane_layout.c b/desktop/src/ui/kosmos_pane_layout.c index 1c442b30..19f1c441 100644 --- a/desktop/src/ui/kosmos_pane_layout.c +++ b/desktop/src/ui/kosmos_pane_layout.c @@ -1,51 +1,204 @@ #include "ui/kosmos_main_window_private.h" +#define KOSMOS_PANED_RATIO_EPSILON 0.001 + typedef struct { + KosmosMainWindow *window; GtkOrientation orientation; double ratio; + guint serial; + guint layout_apply_serial; + gboolean pending_layout_apply; } PanedRatio; -static void set_paned_ratio(KosmosMainWindow *self, const char *path, double ratio) { - double *stored_ratio = g_new(double, 1); - *stored_ratio = CLAMP(ratio, 0.05, 0.95); - g_hash_table_replace(self->paned_ratios, g_strdup(path), stored_ratio); +static void set_paned_ratio_after_layout(KosmosMainWindow *self, GtkWidget *paned, GtkOrientation orientation, double ratio); + +static GtkWidget *layout_content_area(KosmosMainWindow *self) { + return self->staged_content_area == NULL ? self->content_area : self->staged_content_area; +} + +static void reveal_hidden_layout_if_ready(KosmosMainWindow *self) { + if (!self->hiding_layout_apply || self->pending_layout_applies > 0) { + return; + } + + self->hiding_layout_apply = FALSE; + if (self->staged_content_area == NULL) { + return; + } + + GtkWidget *staged_content_area = g_object_ref(self->staged_content_area); + gtk_widget_set_opacity(staged_content_area, 1.0); + gtk_widget_set_can_target(staged_content_area, TRUE); + gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), staged_content_area); + gtk_overlay_set_child(GTK_OVERLAY(self->content_overlay), staged_content_area); + self->content_area = staged_content_area; + self->staged_content_area = NULL; + g_object_unref(staged_content_area); +} + +void kosmos_pane_layout_begin_hidden_apply(KosmosMainWindow *self) { + if (self->staged_content_area != NULL) { + gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); + self->staged_content_area = NULL; + } + + self->layout_apply_serial++; + self->pending_layout_applies = 0; + self->hiding_layout_apply = TRUE; + + self->staged_content_area = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(self->staged_content_area, TRUE); + gtk_widget_set_vexpand(self->staged_content_area, TRUE); + gtk_widget_set_opacity(self->staged_content_area, 0.0); + gtk_widget_set_can_target(self->staged_content_area, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); + g_hash_table_remove_all(self->pane_views); +} + +void kosmos_pane_layout_finish_hidden_apply(KosmosMainWindow *self) { + reveal_hidden_layout_if_ready(self); } -static gboolean get_paned_ratio(KosmosMainWindow *self, const char *path, double *ratio) { - double *stored_ratio = g_hash_table_lookup(self->paned_ratios, path); +static void finish_paned_ratio_apply(PanedRatio *ratio) { + KosmosMainWindow *self = ratio->window; + + if (!ratio->pending_layout_apply || ratio->layout_apply_serial != self->layout_apply_serial) { + return; + } + + ratio->pending_layout_apply = FALSE; + if (self->pending_layout_applies > 0) { + self->pending_layout_applies--; + } + reveal_hidden_layout_if_ready(self); +} - if (stored_ratio == NULL) { +static void paned_ratio_free(PanedRatio *ratio) { + finish_paned_ratio_apply(ratio); + g_object_unref(ratio->window); + g_free(ratio); +} + +static gboolean clear_applying_paned_ratio(gpointer user_data) { + GObject *object = user_data; + g_object_set_data(object, "applying-server-ratio", NULL); + g_object_unref(object); + return G_SOURCE_REMOVE; +} + +static gboolean get_double_data(GObject *object, const char *key, double *value) { + double *stored_value = g_object_get_data(object, key); + + if (stored_value == NULL) { return FALSE; } - *ratio = *stored_ratio; + *value = *stored_value; return TRUE; } -static void remember_paned_ratios_from_widget(KosmosMainWindow *self, GtkWidget *widget) { - if (GTK_IS_PANED(widget)) { - const char *path = g_object_get_data(G_OBJECT(widget), "pane-path"); - GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); - int size = orientation == GTK_ORIENTATION_HORIZONTAL - ? gtk_widget_get_width(widget) - : gtk_widget_get_height(widget); +static void set_double_data(GObject *object, const char *key, double value) { + double *stored_value = g_new(double, 1); + *stored_value = value; + g_object_set_data_full(object, key, stored_value, g_free); +} - if (path != NULL && size > 1) { - set_paned_ratio(self, path, (double)gtk_paned_get_position(GTK_PANED(widget)) / size); - } +static gboolean ratios_match(double first, double second) { + return ABS(first - second) <= KOSMOS_PANED_RATIO_EPSILON; +} + +static void remember_server_paned_ratio(GtkWidget *paned, guint64 workspace_id, guint64 split_id, double ratio) { + GObject *object = G_OBJECT(paned); + kosmos_main_window_set_uint64_data(object, "server-ratio-workspace-id", workspace_id); + kosmos_main_window_set_uint64_data(object, "server-ratio-split-id", split_id); + set_double_data(object, "server-ratio", ratio); +} + +static gboolean paned_already_has_server_ratio(GtkWidget *paned, guint64 workspace_id, guint64 split_id, double ratio) { + guint64 stored_workspace_id = 0; + guint64 stored_split_id = 0; + double stored_ratio = 0.0; + + return kosmos_main_window_get_uint64_data(G_OBJECT(paned), "server-ratio-workspace-id", &stored_workspace_id) && + kosmos_main_window_get_uint64_data(G_OBJECT(paned), "server-ratio-split-id", &stored_split_id) && + get_double_data(G_OBJECT(paned), "server-ratio", &stored_ratio) && + stored_workspace_id == workspace_id && + stored_split_id == split_id && + ratios_match(stored_ratio, ratio); +} + +static guint next_paned_ratio_serial(GtkWidget *paned) { + guint serial = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(paned), "server-ratio-serial")) + 1; + g_object_set_data(G_OBJECT(paned), "server-ratio-serial", GUINT_TO_POINTER(serial)); + return serial; +} + +static void request_split_resize(KosmosMainWindow *self, guint64 workspace_id, guint64 split_id, double ratio) { + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + kosmos_ipc_client_resize_pane_split( + self->ipc_client, + workspace_id, + split_id, + ratio, + NULL, + NULL, + &error + ); + + if (error != NULL) { + g_warning("Failed to resize pane split: %s", error->message); + g_clear_error(&error); + } +} + +static void paned_position_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { + (void)pspec; + + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + if (self->applying_server_state || g_object_get_data(object, "applying-server-ratio") != NULL) { + return; } - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - remember_paned_ratios_from_widget(self, child); + GtkWidget *widget = GTK_WIDGET(object); + GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); + int size = orientation == GTK_ORIENTATION_HORIZONTAL + ? gtk_widget_get_width(widget) + : gtk_widget_get_height(widget); + if (size <= 1) { + return; } + + guint64 workspace_id = 0; + guint64 split_id = 0; + if (!kosmos_main_window_get_uint64_data(object, "workspace-id", &workspace_id) || + !kosmos_main_window_get_uint64_data(object, "split-id", &split_id)) { + return; + } + + double ratio = CLAMP((double)gtk_paned_get_position(GTK_PANED(widget)) / size, 0.05, 0.95); + if (paned_already_has_server_ratio(widget, workspace_id, split_id, ratio)) { + return; + } + + remember_server_paned_ratio(widget, workspace_id, split_id, ratio); + request_split_resize(self, workspace_id, split_id, ratio); } -static void remember_paned_ratios(KosmosMainWindow *self) { - if (self->content_area == NULL) { +static void configure_paned_split(KosmosMainWindow *self, GtkWidget *paned, guint64 workspace_id, guint64 split_id) { + kosmos_main_window_set_uint64_data(G_OBJECT(paned), "workspace-id", workspace_id); + kosmos_main_window_set_uint64_data(G_OBJECT(paned), "split-id", split_id); + + if (g_object_get_data(G_OBJECT(paned), "split-resize-handler-installed") != NULL) { return; } - remember_paned_ratios_from_widget(self, self->content_area); + g_signal_connect(paned, "notify::position", G_CALLBACK(paned_position_changed), self); + g_object_set_data(G_OBJECT(paned), "split-resize-handler-installed", GINT_TO_POINTER(1)); } static gboolean append_layout_signature(GString *signature, JsonObject *node) { @@ -67,13 +220,17 @@ static gboolean append_layout_signature(GString *signature, JsonObject *node) { if (g_strcmp0(type, "split") == 0) { const char *axis = NULL; + guint64 split_id = 0; JsonObject *first = kosmos_json_get_object_member(node, "first"); JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + if (!kosmos_json_get_uint_member(node, "id", &split_id) || + !kosmos_json_get_string_member(node, "axis", &axis) || + first == NULL || + second == NULL) { return FALSE; } - g_string_append_printf(signature, "S:%s(", axis); + g_string_append_printf(signature, "S:%" G_GUINT64_FORMAT ":%s(", split_id, axis); if (!append_layout_signature(signature, first)) { return FALSE; } @@ -90,11 +247,13 @@ static gboolean append_layout_signature(GString *signature, JsonObject *node) { char *kosmos_pane_layout_create_signature(JsonObject *workspace) { JsonObject *root = kosmos_json_get_object_member(workspace, "root"); - if (root == NULL) { + guint64 workspace_id = 0; + if (root == NULL || !kosmos_json_get_uint_member(workspace, "id", &workspace_id)) { return NULL; } GString *signature = g_string_new(NULL); + g_string_append_printf(signature, "W:%" G_GUINT64_FORMAT ";", workspace_id); if (!append_layout_signature(signature, root)) { g_string_free(signature, TRUE); return NULL; @@ -103,52 +262,15 @@ char *kosmos_pane_layout_create_signature(JsonObject *workspace) { return g_string_free(signature, FALSE); } -static void collect_split_paths(JsonObject *node, const char *path, GHashTable *split_paths) { - const char *type = NULL; - if (!kosmos_json_get_string_member(node, "type", &type)) { - return; - } - - if (g_strcmp0(type, "split") != 0) { - return; - } - - JsonObject *first = kosmos_json_get_object_member(node, "first"); - JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (first == NULL || second == NULL) { - return; - } - - g_hash_table_add(split_paths, g_strdup(path)); - - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - collect_split_paths(first, first_path, split_paths); - collect_split_paths(second, second_path, split_paths); - g_free(first_path); - g_free(second_path); -} - -static void prune_paned_ratios_for_root(KosmosMainWindow *self, JsonObject *root) { - GHashTable *split_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); - collect_split_paths(root, "root", split_paths); - - GHashTableIter iter; - gpointer key = NULL; - gpointer value = NULL; - g_hash_table_iter_init(&iter, self->paned_ratios); - while (g_hash_table_iter_next(&iter, &key, &value)) { - if (!g_hash_table_contains(split_paths, key)) { - g_hash_table_iter_remove(&iter); - } - } - - g_hash_table_unref(split_paths); -} - -static gboolean update_pane_node_in_place(KosmosMainWindow *self, JsonObject *node, guint64 workspace_id, guint64 active_pane_id) { +static gboolean update_pane_node_in_place( + KosmosMainWindow *self, + GtkWidget *current, + JsonObject *node, + guint64 workspace_id, + guint64 active_pane_id +) { const char *type = NULL; - if (!kosmos_json_get_string_member(node, "type", &type)) { + if (current == NULL || !kosmos_json_get_string_member(node, "type", &type)) { return FALSE; } @@ -158,12 +280,31 @@ static gboolean update_pane_node_in_place(KosmosMainWindow *self, JsonObject *no } if (g_strcmp0(type, "split") == 0) { + const char *axis = NULL; + guint64 split_id = 0; + double ratio = 0.5; JsonObject *first = kosmos_json_get_object_member(node, "first"); JsonObject *second = kosmos_json_get_object_member(node, "second"); - return first != NULL && - second != NULL && - update_pane_node_in_place(self, first, workspace_id, active_pane_id) && - update_pane_node_in_place(self, second, workspace_id, active_pane_id); + if (!GTK_IS_PANED(current) || + !kosmos_json_get_uint_member(node, "id", &split_id) || + !kosmos_json_get_string_member(node, "axis", &axis) || + first == NULL || + second == NULL) { + return FALSE; + } + + kosmos_json_get_double_member(node, "ratio", &ratio); + GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 + ? GTK_ORIENTATION_VERTICAL + : GTK_ORIENTATION_HORIZONTAL; + gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); + gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); + configure_paned_split(self, current, workspace_id, split_id); + set_paned_ratio_after_layout(self, current, orientation, ratio); + + return update_pane_node_in_place(self, gtk_paned_get_start_child(GTK_PANED(current)), first, workspace_id, active_pane_id) && + update_pane_node_in_place(self, gtk_paned_get_end_child(GTK_PANED(current)), second, workspace_id, active_pane_id); } return FALSE; @@ -180,13 +321,20 @@ gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *s return FALSE; } - return update_pane_node_in_place(self, root, workspace_id, active_pane_id); + GtkWidget *current_root = gtk_widget_get_first_child(layout_content_area(self)); + return update_pane_node_in_place(self, current_root, root, workspace_id, active_pane_id); } static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer user_data) { (void)frame_clock; PanedRatio *ratio = user_data; + guint current_serial = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(widget), "server-ratio-serial")); + if (current_serial != ratio->serial) { + finish_paned_ratio_apply(ratio); + return G_SOURCE_REMOVE; + } + int size = ratio->orientation == GTK_ORIENTATION_HORIZONTAL ? gtk_widget_get_width(widget) : gtk_widget_get_height(widget); @@ -195,15 +343,54 @@ static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, return G_SOURCE_CONTINUE; } - gtk_paned_set_position(GTK_PANED(widget), (int)(size * ratio->ratio)); + int position = (int)((size * ratio->ratio) + 0.5); + if (ABS(gtk_paned_get_position(GTK_PANED(widget)) - position) <= 1) { + g_object_set_data(G_OBJECT(widget), "applying-server-ratio", NULL); + finish_paned_ratio_apply(ratio); + return G_SOURCE_REMOVE; + } + + g_object_set_data(G_OBJECT(widget), "applying-server-ratio", GINT_TO_POINTER(1)); + gtk_paned_set_position(GTK_PANED(widget), position); + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + clear_applying_paned_ratio, + g_object_ref(widget), + NULL + ); + finish_paned_ratio_apply(ratio); return G_SOURCE_REMOVE; } -static void set_paned_ratio_after_layout(GtkWidget *paned, GtkOrientation orientation, double ratio) { +static void set_paned_ratio_after_layout(KosmosMainWindow *self, GtkWidget *paned, GtkOrientation orientation, double ratio) { + guint64 workspace_id = 0; + guint64 split_id = 0; + ratio = CLAMP(ratio, 0.05, 0.95); + + if (kosmos_main_window_get_uint64_data(G_OBJECT(paned), "workspace-id", &workspace_id) && + kosmos_main_window_get_uint64_data(G_OBJECT(paned), "split-id", &split_id) && + paned_already_has_server_ratio(paned, workspace_id, split_id, ratio)) { + return; + } + + if (workspace_id != 0 && split_id != 0) { + remember_server_paned_ratio(paned, workspace_id, split_id, ratio); + } + + g_object_set_data(G_OBJECT(paned), "applying-server-ratio", GINT_TO_POINTER(1)); + PanedRatio *ratio_data = g_new(PanedRatio, 1); + ratio_data->window = g_object_ref(self); ratio_data->orientation = orientation; - ratio_data->ratio = CLAMP(ratio, 0.05, 0.95); - gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, g_free); + ratio_data->ratio = ratio; + ratio_data->serial = next_paned_ratio_serial(paned); + ratio_data->layout_apply_serial = self->layout_apply_serial; + ratio_data->pending_layout_apply = self->hiding_layout_apply; + if (ratio_data->pending_layout_apply) { + self->pending_layout_applies++; + } + + gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, (GDestroyNotify)paned_ratio_free); } static GtkWidget *create_pane_node( @@ -224,15 +411,18 @@ static GtkWidget *create_split_node( GHashTable *reusable_panes ) { const char *axis = NULL; + guint64 split_id = 0; double ratio = 0.5; JsonObject *first = kosmos_json_get_object_member(node, "first"); JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + if (!kosmos_json_get_uint_member(node, "id", &split_id) || + !kosmos_json_get_string_member(node, "axis", &axis) || + first == NULL || + second == NULL) { return kosmos_main_window_create_label("Invalid pane split.", "error"); } kosmos_json_get_double_member(node, "ratio", &ratio); - get_paned_ratio(self, path, &ratio); GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 ? GTK_ORIENTATION_VERTICAL @@ -244,10 +434,10 @@ static GtkWidget *create_split_node( gtk_widget_set_vexpand(paned, TRUE); gtk_paned_set_shrink_start_child(GTK_PANED(paned), FALSE); gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); - g_object_set_data_full(G_OBJECT(paned), "pane-path", g_strdup(path), g_free); + configure_paned_split(self, paned, workspace_id, split_id); gtk_paned_set_start_child(GTK_PANED(paned), create_pane_node(self, first, workspace_id, active_pane_id, first_path, reusable_panes)); gtk_paned_set_end_child(GTK_PANED(paned), create_pane_node(self, second, workspace_id, active_pane_id, second_path, reusable_panes)); - set_paned_ratio_after_layout(paned, orientation, ratio); + set_paned_ratio_after_layout(self, paned, orientation, ratio); g_free(first_path); g_free(second_path); return paned; @@ -329,15 +519,18 @@ static GtkWidget *reconcile_pane_node( if (g_strcmp0(type, "split") == 0) { const char *axis = NULL; + guint64 split_id = 0; double ratio = 0.5; JsonObject *first = kosmos_json_get_object_member(node, "first"); JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_string_member(node, "axis", &axis) || first == NULL || second == NULL) { + if (!kosmos_json_get_uint_member(node, "id", &split_id) || + !kosmos_json_get_string_member(node, "axis", &axis) || + first == NULL || + second == NULL) { return kosmos_main_window_create_label("Invalid pane split.", "error"); } kosmos_json_get_double_member(node, "ratio", &ratio); - get_paned_ratio(self, path, &ratio); GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 ? GTK_ORIENTATION_VERTICAL @@ -355,7 +548,7 @@ static GtkWidget *reconcile_pane_node( gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); - g_object_set_data_full(G_OBJECT(current), "pane-path", g_strdup(path), g_free); + configure_paned_split(self, current, workspace_id, split_id); GtkWidget *old_start = gtk_paned_get_start_child(GTK_PANED(current)); GtkWidget *new_start = reconcile_pane_node(self, old_start, first, workspace_id, active_pane_id, first_path); @@ -369,7 +562,7 @@ static GtkWidget *reconcile_pane_node( gtk_paned_set_end_child(GTK_PANED(current), new_end); } - set_paned_ratio_after_layout(current, orientation, ratio); + set_paned_ratio_after_layout(self, current, orientation, ratio); g_free(first_path); g_free(second_path); return current; @@ -379,10 +572,7 @@ static GtkWidget *reconcile_pane_node( } void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace) { - remember_paned_ratios(self); - if (workspace == NULL) { - g_hash_table_remove_all(self->paned_ratios); kosmos_main_window_clear_content_area(self); return; } @@ -398,19 +588,18 @@ void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObje return; } - prune_paned_ratios_for_root(self, root); - - GtkWidget *current_root = gtk_widget_get_first_child(self->content_area); + GtkWidget *content_area = layout_content_area(self); + GtkWidget *current_root = gtk_widget_get_first_child(content_area); if (current_root != NULL) { g_hash_table_remove_all(self->pane_views); GtkWidget *pane_tree = reconcile_pane_node(self, current_root, root, workspace_id, active_pane_id, "root"); if (pane_tree != current_root) { - gtk_box_remove(GTK_BOX(self->content_area), current_root); - gtk_box_append(GTK_BOX(self->content_area), pane_tree); + gtk_box_remove(GTK_BOX(content_area), current_root); + gtk_box_append(GTK_BOX(content_area), pane_tree); } return; } GtkWidget *pane_tree = create_pane_node(self, root, workspace_id, active_pane_id, "root", NULL); - gtk_box_append(GTK_BOX(self->content_area), pane_tree); + gtk_box_append(GTK_BOX(content_area), pane_tree); } diff --git a/desktop/src/ui/kosmos_workspace_switcher.c b/desktop/src/ui/kosmos_workspace_switcher.c index 88037aa1..771f53b2 100644 --- a/desktop/src/ui/kosmos_workspace_switcher.c +++ b/desktop/src/ui/kosmos_workspace_switcher.c @@ -32,6 +32,11 @@ static void activate_workspace(GtkWidget *button, gpointer user_data) { KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); guint64 *workspace_id = g_object_get_data(G_OBJECT(button), "workspace-id"); + if (GTK_IS_TOGGLE_BUTTON(button) && !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button))) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), TRUE); + return; + } + if (workspace_id == NULL || !kosmos_main_window_ensure_connected(self)) { return; } @@ -254,8 +259,12 @@ static JsonObject *find_active_workspace(JsonArray *workspaces, guint64 active_w } void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { + gboolean was_applying = self->applying_server_state; + self->applying_server_state = TRUE; + if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { kosmos_main_window_set_status(self, "Workspace state is unavailable."); + self->applying_server_state = was_applying; return; } @@ -263,6 +272,7 @@ void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode * JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { kosmos_main_window_set_status(self, "Workspace state is missing workspaces."); + self->applying_server_state = was_applying; return; } @@ -306,22 +316,43 @@ void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode * (void)workspace_count; if (active_workspace != NULL) { + gboolean hide_until_layout_applied = gtk_widget_get_first_child(self->content_area) != NULL && + (!self->has_rendered_workspace || self->rendered_workspace_id != active_workspace_id); char *layout_signature = kosmos_pane_layout_create_signature(active_workspace); + + if (hide_until_layout_applied) { + kosmos_pane_layout_begin_hidden_apply(self); + } + if (layout_signature != NULL && self->layout_signature != NULL && g_strcmp0(layout_signature, self->layout_signature) == 0 && kosmos_pane_layout_update_active_workspace_in_place(self, active_workspace)) { g_free(layout_signature); + self->rendered_workspace_id = active_workspace_id; + self->has_rendered_workspace = TRUE; + if (hide_until_layout_applied) { + kosmos_pane_layout_finish_hidden_apply(self); + } + self->applying_server_state = was_applying; return; } kosmos_pane_layout_render_active_workspace(self, active_workspace); g_clear_pointer(&self->layout_signature, g_free); self->layout_signature = layout_signature; + self->rendered_workspace_id = active_workspace_id; + self->has_rendered_workspace = TRUE; + if (hide_until_layout_applied) { + kosmos_pane_layout_finish_hidden_apply(self); + } + self->applying_server_state = was_applying; return; } kosmos_pane_layout_render_active_workspace(self, active_workspace); + self->has_rendered_workspace = FALSE; + self->applying_server_state = was_applying; } GtkWidget *kosmos_workspace_switcher_create(KosmosMainWindow *self) { diff --git a/server/src/ipc/messages/pane.rs b/server/src/ipc/messages/pane.rs index 751d6abf..9b6a5a51 100644 --- a/server/src/ipc/messages/pane.rs +++ b/server/src/ipc/messages/pane.rs @@ -1,4 +1,4 @@ -use core::tree::{Pane, PaneId, PaneNode, SplitAxis}; +use core::tree::{Pane, PaneId, PaneNode, SplitAxis, SplitPaneId}; use serde::{Deserialize, Serialize}; use super::tab::TabSnapshot; @@ -29,6 +29,14 @@ pub(crate) struct MovePaneParams { pub(crate) new_pane_first: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResizeSplitParams { + pub(crate) workspace_id: Option, + pub(crate) split_id: SplitPaneIdParam, + pub(crate) ratio: f32, +} + #[derive(Clone, Copy, Debug, Deserialize)] pub(crate) struct WorkspaceIdParam(u64); @@ -47,6 +55,15 @@ impl From for PaneId { } } +#[derive(Clone, Copy, Debug, Deserialize)] +pub(crate) struct SplitPaneIdParam(u64); + +impl From for SplitPaneId { + fn from(value: SplitPaneIdParam) -> Self { + Self::new(value.0) + } +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum SplitAxisPayload { @@ -79,6 +96,7 @@ pub(crate) enum PaneNodeSnapshot { pane: PaneSnapshot, }, Split { + id: u64, axis: SplitAxisPayload, ratio: f32, first: Box, @@ -93,6 +111,7 @@ impl PaneNodeSnapshot { pane: PaneSnapshot::from_pane(pane), }, PaneNode::Split(split) => Self::Split { + id: split.id().value(), axis: split.axis().into(), ratio: split.ratio(), first: Box::new(Self::from_node(split.first())), diff --git a/server/src/ipc/router/pane.rs b/server/src/ipc/router/pane.rs index 7ddbe763..46259d18 100644 --- a/server/src/ipc/router/pane.rs +++ b/server/src/ipc/router/pane.rs @@ -1,5 +1,7 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; -use super::super::messages::pane::{ActivatePaneParams, MovePaneParams, SplitPaneParams}; +use super::super::messages::pane::{ + ActivatePaneParams, MovePaneParams, ResizeSplitParams, SplitPaneParams, +}; use super::{command_response, parse_params, unsupported_action}; pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { @@ -7,6 +9,7 @@ pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve "split" => split_pane(state, request), "activate" => activate_pane(state, request), "move" => move_pane(state, request), + "resize" => resize_split(state, request), _ => unsupported_action(request), } } @@ -60,3 +63,20 @@ fn activate_pane(state: &mut core::State, request: &RequestEnvelope) -> ServerMe Err(response) => response, } } + +fn resize_split(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.resize_split( + params.workspace_id.map(Into::into), + params.split_id.into(), + params.ratio, + ), + state, + "pane.resize_failed", + "pane split could not be resized", + ), + Err(response) => response, + } +} From a285192101b39a8a1e19a5fd8a6737dedf9a4405 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 01:30:01 -0300 Subject: [PATCH 11/98] feat: persist server state in sqlite --- Cargo.lock | 110 ++++ core/Cargo.toml | 1 + core/src/lib.rs | 1 + core/src/persistence/mod.rs | 663 +++++++++++++++++++++++++ core/src/state.rs | 97 ++++ server/src/ipc/messages/envelope.rs | 6 + server/src/ipc/transport/connection.rs | 23 +- server/src/ipc/transport/server.rs | 10 +- server/src/main.rs | 31 +- 9 files changed, 936 insertions(+), 6 deletions(-) create mode 100644 core/src/persistence/mod.rs diff --git a/Cargo.lock b/Cargo.lock index e54cb861..fca81784 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,70 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "core" version = "0.1.0" +dependencies = [ + "rusqlite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] [[package]] name = "itoa" @@ -21,12 +82,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -45,6 +123,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "serde" version = "1.0.228" @@ -88,6 +180,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "syn" version = "2.0.117" @@ -105,6 +209,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "zmij" version = "1.0.21" diff --git a/core/Cargo.toml b/core/Cargo.toml index 143436c4..e3f05aa8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] +rusqlite = { version = "0.37.0", features = ["bundled"] } diff --git a/core/src/lib.rs b/core/src/lib.rs index 72a69e48..f5aaff37 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,4 +1,5 @@ mod state; +pub mod persistence; pub use state::State; pub mod tree; diff --git a/core/src/persistence/mod.rs b/core/src/persistence/mod.rs new file mode 100644 index 00000000..75202127 --- /dev/null +++ b/core/src/persistence/mod.rs @@ -0,0 +1,663 @@ +use std::error::Error as StdError; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OptionalExtension, Transaction, params}; + +use crate::State; +use crate::tree::{ + Pane, PaneId, PaneNode, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, +}; + +pub type Result = std::result::Result; + +#[derive(Clone, Debug)] +pub struct StateStore { + path: PathBuf, +} + +impl StateStore { + pub fn open(path: impl Into) -> Result { + let path = path.into(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let store = Self { path }; + store.connection()?; + + Ok(store) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result { + let connection = self.connection()?; + + load_state(&connection) + } + + pub fn save(&self, state: &State) -> Result<()> { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + + clear_state(&transaction)?; + save_state(&transaction, state)?; + transaction.commit()?; + + Ok(()) + } + + fn connection(&self) -> Result { + let connection = Connection::open(&self.path)?; + migrate(&connection)?; + + Ok(connection) + } +} + +#[derive(Debug)] +pub enum PersistenceError { + Io(std::io::Error), + Sqlite(rusqlite::Error), + InvalidState(String), +} + +impl fmt::Display for PersistenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "{error}"), + Self::Sqlite(error) => write!(formatter, "{error}"), + Self::InvalidState(message) => formatter.write_str(message), + } + } +} + +impl StdError for PersistenceError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Sqlite(error) => Some(error), + Self::InvalidState(_) => None, + } + } +} + +impl From for PersistenceError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PersistenceError { + fn from(error: rusqlite::Error) -> Self { + Self::Sqlite(error) + } +} + +fn migrate(connection: &Connection) -> Result<()> { + connection.execute_batch( + " + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY NOT NULL, + position INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL, + directory TEXT NOT NULL, + active_pane_id INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pane_nodes ( + workspace_id INTEGER NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('leaf', 'split')), + pane_id INTEGER, + split_id INTEGER, + axis TEXT, + ratio REAL, + PRIMARY KEY (workspace_id, path), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS panes ( + workspace_id INTEGER NOT NULL, + id INTEGER NOT NULL, + active_tab_id INTEGER NOT NULL, + PRIMARY KEY (workspace_id, id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS tabs ( + workspace_id INTEGER NOT NULL, + pane_id INTEGER NOT NULL, + position INTEGER NOT NULL, + id INTEGER NOT NULL, + title TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY (workspace_id, id), + UNIQUE (workspace_id, pane_id, position), + FOREIGN KEY (workspace_id, pane_id) REFERENCES panes(workspace_id, id) ON DELETE CASCADE + ); + ", + )?; + + Ok(()) +} + +fn clear_state(transaction: &Transaction<'_>) -> Result<()> { + transaction.execute("DELETE FROM metadata", [])?; + transaction.execute("DELETE FROM tabs", [])?; + transaction.execute("DELETE FROM panes", [])?; + transaction.execute("DELETE FROM pane_nodes", [])?; + transaction.execute("DELETE FROM workspaces", [])?; + + Ok(()) +} + +fn save_state(transaction: &Transaction<'_>, state: &State) -> Result<()> { + if let Some(active_workspace_id) = state.workspaces().active_workspace_id() { + transaction.execute( + "INSERT INTO metadata (key, value) VALUES ('active_workspace_id', ?1)", + params![active_workspace_id.value().to_string()], + )?; + } + + for (position, workspace) in state.workspaces().workspaces().iter().enumerate() { + let workspace_id = to_i64(workspace.id().value(), "workspace id")?; + let active_pane_id = to_i64(workspace.active_pane_id().value(), "active pane id")?; + + transaction.execute( + " + INSERT INTO workspaces (id, position, name, directory, active_pane_id) + VALUES (?1, ?2, ?3, ?4, ?5) + ", + params![ + workspace_id, + usize_to_i64(position, "workspace position")?, + workspace.name(), + workspace.directory().to_string_lossy(), + active_pane_id, + ], + )?; + + save_node(transaction, workspace.id(), "", workspace.root())?; + } + + Ok(()) +} + +fn save_node( + transaction: &Transaction<'_>, + workspace_id: WorkspaceId, + path: &str, + node: &PaneNode, +) -> Result<()> { + match node { + PaneNode::Leaf(pane) => { + transaction.execute( + " + INSERT INTO pane_nodes (workspace_id, path, kind, pane_id) + VALUES (?1, ?2, 'leaf', ?3) + ", + params![ + to_i64(workspace_id.value(), "workspace id")?, + path, + to_i64(pane.id().value(), "pane id")?, + ], + )?; + save_pane(transaction, workspace_id, pane) + } + PaneNode::Split(split) => { + transaction.execute( + " + INSERT INTO pane_nodes (workspace_id, path, kind, split_id, axis, ratio) + VALUES (?1, ?2, 'split', ?3, ?4, ?5) + ", + params![ + to_i64(workspace_id.value(), "workspace id")?, + path, + to_i64(split.id().value(), "split id")?, + split_axis_name(split.axis()), + split.ratio(), + ], + )?; + + save_node( + transaction, + workspace_id, + &child_path(path, 0), + split.first(), + )?; + save_node( + transaction, + workspace_id, + &child_path(path, 1), + split.second(), + ) + } + } +} + +fn save_pane(transaction: &Transaction<'_>, workspace_id: WorkspaceId, pane: &Pane) -> Result<()> { + transaction.execute( + " + INSERT INTO panes (workspace_id, id, active_tab_id) + VALUES (?1, ?2, ?3) + ", + params![ + to_i64(workspace_id.value(), "workspace id")?, + to_i64(pane.id().value(), "pane id")?, + to_i64(pane.active_tab_id().value(), "active tab id")?, + ], + )?; + + for (position, tab) in pane.tabs().iter().enumerate() { + transaction.execute( + " + INSERT INTO tabs (workspace_id, pane_id, position, id, title, kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", + params![ + to_i64(workspace_id.value(), "workspace id")?, + to_i64(pane.id().value(), "pane id")?, + usize_to_i64(position, "tab position")?, + to_i64(tab.id().value(), "tab id")?, + tab.title(), + tab_kind_name(tab.kind()), + ], + )?; + } + + Ok(()) +} + +fn load_state(connection: &Connection) -> Result { + let active_workspace_id = load_active_workspace_id(connection)?; + let workspace_rows = load_workspace_rows(connection)?; + let mut workspaces = Vec::with_capacity(workspace_rows.len()); + + for workspace_row in workspace_rows { + let root = load_node(connection, workspace_row.id, "")?; + let mut workspace = Workspace::from_root( + workspace_row.id, + workspace_row.directory, + root, + workspace_row.active_pane_id, + ) + .ok_or_else(|| invalid_state("workspace active pane does not exist in its root"))?; + + workspace.rename(workspace_row.name); + workspaces.push(workspace); + } + + State::from_workspaces(workspaces, active_workspace_id) + .ok_or_else(|| invalid_state("persisted workspaces are not internally consistent")) +} + +fn load_active_workspace_id(connection: &Connection) -> Result> { + let value = connection + .query_row( + "SELECT value FROM metadata WHERE key = 'active_workspace_id'", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + + value + .map(|value| { + value + .parse::() + .map(WorkspaceId::new) + .map_err(|_| invalid_state("active workspace id is not a valid integer")) + }) + .transpose() +} + +#[derive(Debug)] +struct WorkspaceRow { + id: WorkspaceId, + name: String, + directory: PathBuf, + active_pane_id: PaneId, +} + +fn load_workspace_rows(connection: &Connection) -> Result> { + let mut statement = connection.prepare( + " + SELECT id, name, directory, active_pane_id + FROM workspaces + ORDER BY position + ", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + })?; + + let mut workspace_rows = Vec::new(); + + for row in rows { + let (id, name, directory, active_pane_id) = row?; + + workspace_rows.push(WorkspaceRow { + id: WorkspaceId::new(to_u64(id, "workspace id")?), + name, + directory: PathBuf::from(directory), + active_pane_id: PaneId::new(to_u64(active_pane_id, "active pane id")?), + }); + } + + Ok(workspace_rows) +} + +#[derive(Debug)] +struct NodeRow { + kind: String, + pane_id: Option, + split_id: Option, + axis: Option, + ratio: Option, +} + +fn load_node(connection: &Connection, workspace_id: WorkspaceId, path: &str) -> Result { + let node = connection + .query_row( + " + SELECT kind, pane_id, split_id, axis, ratio + FROM pane_nodes + WHERE workspace_id = ?1 AND path = ?2 + ", + params![to_i64(workspace_id.value(), "workspace id")?, path], + |row| { + Ok(NodeRow { + kind: row.get(0)?, + pane_id: row.get(1)?, + split_id: row.get(2)?, + axis: row.get(3)?, + ratio: row.get(4)?, + }) + }, + ) + .optional()? + .ok_or_else(|| invalid_state(format!("missing pane node at path {path:?}")))?; + + match node.kind.as_str() { + "leaf" => { + let pane_id = required_i64(node.pane_id, "leaf pane id")?; + let pane = load_pane( + connection, + workspace_id, + PaneId::new(to_u64(pane_id, "pane id")?), + )?; + + Ok(PaneNode::leaf(pane)) + } + "split" => { + let split_id = required_i64(node.split_id, "split id")?; + let axis = parse_split_axis(required_string(node.axis, "split axis")?)?; + let ratio = parse_split_ratio(required_f64(node.ratio, "split ratio")?)?; + let first = load_node(connection, workspace_id, &child_path(path, 0))?; + let second = load_node(connection, workspace_id, &child_path(path, 1))?; + + Ok(PaneNode::split( + SplitPaneId::new(to_u64(split_id, "split id")?), + axis, + ratio, + first, + second, + )) + } + _ => Err(invalid_state(format!( + "unknown pane node kind {:?}", + node.kind + ))), + } +} + +fn load_pane(connection: &Connection, workspace_id: WorkspaceId, pane_id: PaneId) -> Result { + let active_tab_id = connection + .query_row( + " + SELECT active_tab_id + FROM panes + WHERE workspace_id = ?1 AND id = ?2 + ", + params![ + to_i64(workspace_id.value(), "workspace id")?, + to_i64(pane_id.value(), "pane id")?, + ], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| invalid_state(format!("missing pane {}", pane_id.value())))?; + let active_tab_id = TabId::new(to_u64(active_tab_id, "active tab id")?); + let mut tabs = load_tabs(connection, workspace_id, pane_id)?; + + if tabs.is_empty() { + return Err(invalid_state(format!( + "pane {} has no tabs", + pane_id.value() + ))); + } + + let first_tab = tabs.remove(0); + let mut pane = Pane::new(pane_id, first_tab); + + for tab in tabs { + pane.add_tab(tab); + } + + if !pane.activate_tab(active_tab_id) { + return Err(invalid_state(format!( + "pane {} active tab does not exist", + pane_id.value() + ))); + } + + Ok(pane) +} + +fn load_tabs( + connection: &Connection, + workspace_id: WorkspaceId, + pane_id: PaneId, +) -> Result> { + let mut statement = connection.prepare( + " + SELECT id, title, kind + FROM tabs + WHERE workspace_id = ?1 AND pane_id = ?2 + ORDER BY position + ", + )?; + let rows = statement.query_map( + params![ + to_i64(workspace_id.value(), "workspace id")?, + to_i64(pane_id.value(), "pane id")?, + ], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + )?; + let mut tabs = Vec::new(); + + for row in rows { + let (id, title, kind) = row?; + + tabs.push(Tab::new( + TabId::new(to_u64(id, "tab id")?), + title, + parse_tab_kind(&kind)?, + )); + } + + Ok(tabs) +} + +fn child_path(path: &str, index: u8) -> String { + if path.is_empty() { + index.to_string() + } else { + format!("{path}/{index}") + } +} + +fn split_axis_name(axis: SplitAxis) -> &'static str { + match axis { + SplitAxis::Horizontal => "horizontal", + SplitAxis::Vertical => "vertical", + } +} + +fn parse_split_axis(axis: String) -> Result { + match axis.as_str() { + "horizontal" => Ok(SplitAxis::Horizontal), + "vertical" => Ok(SplitAxis::Vertical), + _ => Err(invalid_state(format!("unknown split axis {axis:?}"))), + } +} + +fn tab_kind_name(kind: &TabKind) -> &'static str { + match kind { + TabKind::Blank => "blank", + TabKind::FileTree => "file_tree", + TabKind::Editor => "editor", + TabKind::Git => "git", + TabKind::Search => "search", + TabKind::Terminal => "terminal", + TabKind::Settings => "settings", + } +} + +fn parse_tab_kind(kind: &str) -> Result { + match kind { + "blank" => Ok(TabKind::Blank), + "file_tree" => Ok(TabKind::FileTree), + "editor" => Ok(TabKind::Editor), + "git" => Ok(TabKind::Git), + "search" => Ok(TabKind::Search), + "terminal" => Ok(TabKind::Terminal), + "settings" => Ok(TabKind::Settings), + _ => Err(invalid_state(format!("unknown tab kind {kind:?}"))), + } +} + +fn parse_split_ratio(ratio: f64) -> Result { + let ratio = ratio as f32; + + if ratio.is_finite() && ratio > 0.0 && ratio < 1.0 { + Ok(ratio) + } else { + Err(invalid_state("split ratio must be between 0.0 and 1.0")) + } +} + +fn required_i64(value: Option, field: &str) -> Result { + value.ok_or_else(|| invalid_state(format!("missing {field}"))) +} + +fn required_f64(value: Option, field: &str) -> Result { + value.ok_or_else(|| invalid_state(format!("missing {field}"))) +} + +fn required_string(value: Option, field: &str) -> Result { + value.ok_or_else(|| invalid_state(format!("missing {field}"))) +} + +fn to_i64(value: u64, field: &str) -> Result { + i64::try_from(value).map_err(|_| invalid_state(format!("{field} is too large for SQLite"))) +} + +fn usize_to_i64(value: usize, field: &str) -> Result { + i64::try_from(value).map_err(|_| invalid_state(format!("{field} is too large for SQLite"))) +} + +fn to_u64(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| invalid_state(format!("{field} must not be negative"))) +} + +fn invalid_state(message: impl Into) -> PersistenceError { + PersistenceError::InvalidState(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn loading_empty_store_returns_empty_state() { + let path = test_db_path("empty"); + let store = StateStore::open(&path).expect("store should open"); + + let state = store.load().expect("state should load"); + + assert!(state.workspaces().is_empty()); + + let _ = fs::remove_file(path); + } + + #[test] + fn saves_and_loads_workspace_tree() { + let path = test_db_path("round-trip"); + let store = StateStore::open(&path).expect("store should open"); + let mut state = State::new(); + + state.open_workspace("/workspaces/main"); + assert!(state.open_tab(None, None, "Search", TabKind::Search)); + assert!(state.split_pane(None, None, SplitAxis::Horizontal, false)); + assert!(state.resize_split(None, SplitPaneId::new(1), 0.65)); + assert!(state.activate_pane(None, PaneId::new(1))); + assert!(state.activate_tab(None, PaneId::new(1), TabId::new(2))); + + store.save(&state).expect("state should save"); + + let mut loaded = store.load().expect("state should load"); + + assert_eq!(loaded.workspaces(), state.workspaces()); + + assert!(loaded.open_tab(None, None, "Terminal", TabKind::Terminal)); + let workspace = loaded + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let pane = workspace + .root() + .find_pane(PaneId::new(1)) + .expect("pane should exist"); + + assert!(pane.contains_tab(TabId::new(4))); + + let _ = fs::remove_file(path); + } + + fn test_db_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + + std::env::temp_dir().join(format!( + "kosmos-core-persistence-{}-{name}-{nanos}.sqlite3", + std::process::id() + )) + } +} diff --git a/core/src/state.rs b/core/src/state.rs index 710b0e7c..c4690603 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -19,6 +19,28 @@ impl State { Self::default() } + pub fn from_workspaces( + workspaces: Vec, + active_workspace_id: Option, + ) -> Option { + let mut workspace_list = WorkspaceList::new(); + + for workspace in workspaces { + if !workspace_list.add_workspace(workspace) { + return None; + } + } + + match active_workspace_id { + Some(active_workspace_id) if workspace_list.activate_workspace(active_workspace_id) => { + } + None if workspace_list.is_empty() => {} + _ => return None, + } + + Self::from_workspace_list(workspace_list) + } + pub fn workspaces(&self) -> &WorkspaceList { &self.workspaces } @@ -285,6 +307,81 @@ impl State { fn next_tab(&mut self, title: impl Into, kind: TabKind) -> Tab { Tab::new(self.next_tab_id(), title, kind) } + + fn from_workspace_list(workspaces: WorkspaceList) -> Option { + let next_ids = NextIds::from_workspaces(&workspaces)?; + + Some(Self { + workspaces, + next_workspace_id: next_ids.workspace_id, + next_pane_id: next_ids.pane_id, + next_split_id: next_ids.split_id, + next_tab_id: next_ids.tab_id, + }) + } +} + +#[derive(Debug, Default)] +struct MaxIds { + workspace_id: u64, + pane_id: u64, + split_id: u64, + tab_id: u64, +} + +impl MaxIds { + fn visit_workspace(&mut self, workspace: &Workspace) { + self.workspace_id = self.workspace_id.max(workspace.id().value()); + self.visit_pane_node(workspace.root()); + } + + fn visit_pane_node(&mut self, node: &crate::tree::PaneNode) { + match node { + crate::tree::PaneNode::Leaf(pane) => self.visit_pane(pane), + crate::tree::PaneNode::Split(split) => { + self.split_id = self.split_id.max(split.id().value()); + self.visit_pane_node(split.first()); + self.visit_pane_node(split.second()); + } + } + } + + fn visit_pane(&mut self, pane: &Pane) { + self.pane_id = self.pane_id.max(pane.id().value()); + + for tab in pane.tabs() { + self.tab_id = self.tab_id.max(tab.id().value()); + } + } +} + +#[derive(Debug)] +struct NextIds { + workspace_id: u64, + pane_id: u64, + split_id: u64, + tab_id: u64, +} + +impl NextIds { + fn from_workspaces(workspaces: &WorkspaceList) -> Option { + let mut max_ids = MaxIds::default(); + + for workspace in workspaces.workspaces() { + max_ids.visit_workspace(workspace); + } + + Some(Self { + workspace_id: next_id_after(max_ids.workspace_id)?, + pane_id: next_id_after(max_ids.pane_id)?, + split_id: next_id_after(max_ids.split_id)?, + tab_id: next_id_after(max_ids.tab_id)?, + }) + } +} + +fn next_id_after(id: u64) -> Option { + id.checked_add(1) } impl Default for State { diff --git a/server/src/ipc/messages/envelope.rs b/server/src/ipc/messages/envelope.rs index 544b0825..dad01c1f 100644 --- a/server/src/ipc/messages/envelope.rs +++ b/server/src/ipc/messages/envelope.rs @@ -38,6 +38,12 @@ impl ServerMessage { }), }) } + + pub(crate) fn is_ok(&self) -> bool { + match self { + Self::Response(response) => response.ok, + } + } } #[derive(Debug, Deserialize)] diff --git a/server/src/ipc/transport/connection.rs b/server/src/ipc/transport/connection.rs index fc9068cf..88c65905 100644 --- a/server/src/ipc/transport/connection.rs +++ b/server/src/ipc/transport/connection.rs @@ -7,7 +7,11 @@ use crate::ipc::router; use super::codec; -pub(crate) fn handle(stream: UnixStream, state: Arc>) -> io::Result<()> { +pub(crate) fn handle( + stream: UnixStream, + state: Arc>, + store: Arc, +) -> io::Result<()> { let reader_stream = stream.try_clone()?; let mut reader = BufReader::new(reader_stream); let mut writer = stream; @@ -23,7 +27,22 @@ pub(crate) fn handle(stream: UnixStream, state: Arc>) -> io:: .lock() .map_err(|_| io::Error::other("IPC state mutex was poisoned"))?; - router::route(&mut state, request) + let request_id = request.id; + let response = router::route(&mut state, request); + + if response.is_ok() { + if let Err(error) = store.save(&state) { + ServerMessage::error( + request_id, + "persistence.save_failed", + error.to_string(), + ) + } else { + response + } + } else { + response + } } Err(error) => ServerMessage::error(0, "ipc.invalid_message", error.to_string()), }; diff --git a/server/src/ipc/transport/server.rs b/server/src/ipc/transport/server.rs index d3360337..b61d4efd 100644 --- a/server/src/ipc/transport/server.rs +++ b/server/src/ipc/transport/server.rs @@ -8,11 +8,16 @@ use std::thread; use super::connection; -pub(crate) fn run(socket_path: PathBuf, state: core::State) -> io::Result<()> { +pub(crate) fn run( + socket_path: PathBuf, + state: core::State, + store: core::persistence::StateStore, +) -> io::Result<()> { prepare_socket_path(&socket_path)?; let listener = UnixListener::bind(&socket_path)?; let state = Arc::new(Mutex::new(state)); + let store = Arc::new(store); println!("kosmos server listening on {}", socket_path.display()); @@ -20,9 +25,10 @@ pub(crate) fn run(socket_path: PathBuf, state: core::State) -> io::Result<()> { match stream { Ok(stream) => { let state = Arc::clone(&state); + let store = Arc::clone(&store); thread::spawn(move || { - if let Err(error) = connection::handle(stream, state) { + if let Err(error) = connection::handle(stream, state, store) { eprintln!("IPC connection failed: {error}"); } }); diff --git a/server/src/main.rs b/server/src/main.rs index 199ce131..15744434 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,9 +1,13 @@ mod ipc; +use std::io; use std::path::PathBuf; -fn main() -> std::io::Result<()> { - ipc::run(socket_path(), core::State::new()) +fn main() -> io::Result<()> { + let store = core::persistence::StateStore::open(database_path()?).map_err(io::Error::other)?; + let state = store.load().map_err(io::Error::other)?; + + ipc::run(socket_path(), state, store) } fn socket_path() -> PathBuf { @@ -17,3 +21,26 @@ fn socket_path() -> PathBuf { .join("kosmos") .join("server.sock") } + +fn database_path() -> io::Result { + if let Some(database_path) = std::env::var_os("KOSMOS_DATABASE") { + return Ok(database_path.into()); + } + + Ok(config_dir()?.join("state.sqlite3")) +} + +fn config_dir() -> io::Result { + if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(config_home).join("kosmos")); + } + + let home = std::env::var_os("HOME").ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "HOME must be set when XDG_CONFIG_HOME is not set", + ) + })?; + + Ok(PathBuf::from(home).join(".config").join("kosmos")) +} From 5b0f62beac329b412e3f58b3f5aa59714ff30d5c Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 02:11:25 -0300 Subject: [PATCH 12/98] feat: add blank tab starter --- core/src/state.rs | 36 ++++++ core/src/tree/panes.rs | 11 +- core/src/tree/tabs.rs | 19 +++ core/src/tree/workspaces.rs | 15 ++- desktop/meson.build | 1 + desktop/src/ipc/kosmos_ipc_client.h | 11 ++ desktop/src/ipc/kosmos_ipc_protocol.h | 1 + desktop/src/ipc/kosmos_ipc_tab.c | 30 +++++ desktop/src/tabs/kosmos_blank_tab.c | 106 +++++++++++++++ desktop/src/tabs/kosmos_blank_tab.h | 13 ++ desktop/src/ui/kosmos_tabbed_pane.c | 179 +++++++++++++++++++++++++- server/src/ipc/messages/tab.rs | 9 ++ server/src/ipc/router/tab.rs | 22 +++- 13 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 desktop/src/tabs/kosmos_blank_tab.c create mode 100644 desktop/src/tabs/kosmos_blank_tab.h diff --git a/core/src/state.rs b/core/src/state.rs index c4690603..ac2b25d8 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -164,6 +164,23 @@ impl State { workspace.activate_tab(pane_id, tab_id) } + pub fn set_tab_kind( + &mut self, + workspace_id: Option, + pane_id: PaneId, + tab_id: TabId, + kind: TabKind, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.set_tab_kind(pane_id, tab_id, kind) + } + pub fn split_tab( &mut self, workspace_id: Option, @@ -430,6 +447,25 @@ mod tests { assert_eq!(pane.active_tab().title(), "Search"); } + #[test] + fn setting_tab_kind_updates_kind_and_default_title() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + + assert!(state.set_tab_kind(None, PaneId::new(1), TabId::new(1), TabKind::Git)); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let pane = workspace + .active_pane() + .expect("workspace should have an active pane"); + + assert_eq!(pane.active_tab().title(), "Git"); + assert_eq!(pane.active_tab().kind(), &TabKind::Git); + } + #[test] fn splitting_tab_moves_it_to_a_new_pane() { let mut state = State::new(); diff --git a/core/src/tree/panes.rs b/core/src/tree/panes.rs index 07bc4b60..48913ccb 100644 --- a/core/src/tree/panes.rs +++ b/core/src/tree/panes.rs @@ -1,4 +1,4 @@ -use super::tabs::{Tab, TabId}; +use super::tabs::{Tab, TabId, TabKind}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct PaneId(u64); @@ -392,6 +392,15 @@ impl Pane { } } + pub fn set_tab_kind(&mut self, tab_id: TabId, kind: TabKind) -> bool { + let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id() == tab_id) else { + return false; + }; + + tab.set_kind(kind); + true + } + pub(crate) fn remove_tab(&mut self, tab_id: TabId) -> Option { let tab_index = self.tabs.iter().position(|tab| tab.id() == tab_id)?; let removed_tab = self.tabs.remove(tab_index); diff --git a/core/src/tree/tabs.rs b/core/src/tree/tabs.rs index 64687e27..a3216b5d 100644 --- a/core/src/tree/tabs.rs +++ b/core/src/tree/tabs.rs @@ -39,6 +39,11 @@ impl Tab { self.title = title.into(); } + pub fn set_kind(&mut self, kind: TabKind) { + self.title = kind.default_title().to_owned(); + self.kind = kind; + } + pub fn kind(&self) -> &TabKind { &self.kind } @@ -54,3 +59,17 @@ pub enum TabKind { Terminal, Settings, } + +impl TabKind { + pub fn default_title(&self) -> &'static str { + match self { + Self::Blank => "Blank", + Self::FileTree => "File Tree", + Self::Editor => "Editor", + Self::Git => "Git", + Self::Search => "Search", + Self::Terminal => "Terminal", + Self::Settings => "Settings", + } + } +} diff --git a/core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs index 3a6147f7..3366e810 100644 --- a/core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use super::panes::{Pane, PaneId, PaneNode, SplitAxis, SplitPaneId}; -use super::tabs::{Tab, TabId}; +use super::tabs::{Tab, TabId, TabKind}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct WorkspaceId(u64); @@ -182,6 +182,19 @@ impl Workspace { activated } + pub fn set_tab_kind(&mut self, pane_id: PaneId, tab_id: TabId, kind: TabKind) -> bool { + let updated = self + .root + .find_pane_mut(pane_id) + .is_some_and(|pane| pane.set_tab_kind(tab_id, kind)); + + if updated { + self.active_pane = pane_id; + } + + updated + } + pub fn close_tab( &mut self, pane_id: PaneId, diff --git a/desktop/meson.build b/desktop/meson.build index 5df23e3c..d9475cd9 100644 --- a/desktop/meson.build +++ b/desktop/meson.build @@ -22,6 +22,7 @@ sources = files( 'src/ipc/kosmos_ipc_protocol.c', 'src/ipc/kosmos_ipc_tab.c', 'src/ipc/kosmos_ipc_workspace.c', + 'src/tabs/kosmos_blank_tab.c', 'src/ui/kosmos_main_window.c', 'src/ui/kosmos_pane_dnd.c', 'src/ui/kosmos_pane_layout.c', diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h index b74dfad6..e51e5a5e 100644 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -127,6 +127,17 @@ gboolean kosmos_ipc_client_activate_tab( GError **error ); +gboolean kosmos_ipc_client_set_tab_kind( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + KosmosIpcTabKind kind, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + gboolean kosmos_ipc_client_close_tab( KosmosIpcClient *self, guint64 workspace_id, diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h index e74c91e9..16541347 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -28,6 +28,7 @@ typedef enum { #define KOSMOS_IPC_ACTION_LIST "list" #define KOSMOS_IPC_ACTION_OPEN "open" #define KOSMOS_IPC_ACTION_ACTIVATE "activate" +#define KOSMOS_IPC_ACTION_SET_KIND "setKind" #define KOSMOS_IPC_ACTION_CLOSE "close" #define KOSMOS_IPC_ACTION_SPLIT "split" #define KOSMOS_IPC_ACTION_REORDER "reorder" diff --git a/desktop/src/ipc/kosmos_ipc_tab.c b/desktop/src/ipc/kosmos_ipc_tab.c index 1b10cca8..36f9d94e 100644 --- a/desktop/src/ipc/kosmos_ipc_tab.c +++ b/desktop/src/ipc/kosmos_ipc_tab.c @@ -86,6 +86,36 @@ gboolean kosmos_ipc_client_close_tab( return requested; } +gboolean kosmos_ipc_client_set_tab_kind( + KosmosIpcClient *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + KosmosIpcTabKind kind, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_int_member(params, "paneId", (gint64)pane_id); + json_object_set_int_member(params, "tabId", (gint64)tab_id); + json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_ACTION_SET_KIND, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + gboolean kosmos_ipc_client_reorder_tab( KosmosIpcClient *self, guint64 workspace_id, diff --git a/desktop/src/tabs/kosmos_blank_tab.c b/desktop/src/tabs/kosmos_blank_tab.c new file mode 100644 index 00000000..06b31f7e --- /dev/null +++ b/desktop/src/tabs/kosmos_blank_tab.c @@ -0,0 +1,106 @@ +#include "tabs/kosmos_blank_tab.h" + +#include + +typedef struct { + KosmosIpcTabKind kind; + KosmosBlankTabKindSelectedFunc kind_selected; + gpointer user_data; +} TabKindSelection; + +typedef struct { + const char *label; + KosmosIpcTabKind kind; +} TabKindButton; + +static void tab_kind_selection_free(TabKindSelection *selection) { + g_free(selection); +} + +static void tab_kind_selection_closure_free(gpointer data, GClosure *closure) { + (void)closure; + + tab_kind_selection_free(data); +} + +static void select_tab_kind(GtkButton *button, gpointer user_data) { + (void)button; + + TabKindSelection *selection = user_data; + if (selection->kind_selected != NULL) { + selection->kind_selected(selection->kind, selection->user_data); + } +} + +static GtkWidget *create_tab_kind_button( + const TabKindButton *tab_kind, + KosmosBlankTabKindSelectedFunc kind_selected, + gpointer user_data +) { + GtkWidget *button = gtk_button_new_with_label(tab_kind->label); + TabKindSelection *selection = g_new(TabKindSelection, 1); + + selection->kind = tab_kind->kind; + selection->kind_selected = kind_selected; + selection->user_data = user_data; + + g_signal_connect_data( + button, + "clicked", + G_CALLBACK(select_tab_kind), + selection, + tab_kind_selection_closure_free, + 0 + ); + + return button; +} + +GtkWidget *kosmos_blank_tab_create(KosmosBlankTabKindSelectedFunc kind_selected, gpointer user_data) { + static const TabKindButton tab_kinds[] = { + {"File Tree", KOSMOS_IPC_TAB_KIND_FILE_TREE}, + {"Editor", KOSMOS_IPC_TAB_KIND_EDITOR}, + {"Git", KOSMOS_IPC_TAB_KIND_GIT}, + {"Search", KOSMOS_IPC_TAB_KIND_SEARCH}, + {"Terminal", KOSMOS_IPC_TAB_KIND_TERMINAL}, + {"Settings", KOSMOS_IPC_TAB_KIND_SETTINGS}, + }; + + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *top_spacer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + GtkWidget *buttons = adw_wrap_box_new(); + GtkWidget *bottom_spacer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *label = gtk_label_new("Choose a tab type"); + + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + gtk_widget_set_margin_top(content, 24); + gtk_widget_set_margin_bottom(content, 24); + gtk_widget_set_margin_start(content, 24); + gtk_widget_set_margin_end(content, 24); + gtk_widget_set_vexpand(top_spacer, TRUE); + gtk_widget_set_vexpand(bottom_spacer, TRUE); + gtk_widget_set_halign(list, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(list, TRUE); + gtk_widget_set_halign(label, GTK_ALIGN_CENTER); + gtk_widget_set_halign(buttons, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(buttons, TRUE); + adw_wrap_box_set_child_spacing(ADW_WRAP_BOX(buttons), 8); + adw_wrap_box_set_line_spacing(ADW_WRAP_BOX(buttons), 8); + adw_wrap_box_set_align(ADW_WRAP_BOX(buttons), 0.5f); + adw_wrap_box_set_wrap_policy(ADW_WRAP_BOX(buttons), ADW_WRAP_MINIMUM); + gtk_widget_add_css_class(label, "heading"); + + gtk_box_append(GTK_BOX(list), label); + for (gsize index = 0; index < G_N_ELEMENTS(tab_kinds); index++) { + adw_wrap_box_append(ADW_WRAP_BOX(buttons), create_tab_kind_button(&tab_kinds[index], kind_selected, user_data)); + } + gtk_box_append(GTK_BOX(list), buttons); + + gtk_box_append(GTK_BOX(content), top_spacer); + gtk_box_append(GTK_BOX(content), list); + gtk_box_append(GTK_BOX(content), bottom_spacer); + + return content; +} diff --git a/desktop/src/tabs/kosmos_blank_tab.h b/desktop/src/tabs/kosmos_blank_tab.h new file mode 100644 index 00000000..d0b0ac66 --- /dev/null +++ b/desktop/src/tabs/kosmos_blank_tab.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "ipc/kosmos_ipc_protocol.h" + +G_BEGIN_DECLS + +typedef void (*KosmosBlankTabKindSelectedFunc)(KosmosIpcTabKind kind, gpointer user_data); + +GtkWidget *kosmos_blank_tab_create(KosmosBlankTabKindSelectedFunc kind_selected, gpointer user_data); + +G_END_DECLS diff --git a/desktop/src/ui/kosmos_tabbed_pane.c b/desktop/src/ui/kosmos_tabbed_pane.c index a5daeb01..6bb68b47 100644 --- a/desktop/src/ui/kosmos_tabbed_pane.c +++ b/desktop/src/ui/kosmos_tabbed_pane.c @@ -1,4 +1,5 @@ #include "ui/kosmos_main_window_private.h" +#include "tabs/kosmos_blank_tab.h" typedef struct { guint64 workspace_id; @@ -16,6 +17,13 @@ typedef struct { AdwTabView *view; } DeferredTabActivation; +typedef struct { + KosmosMainWindow *window; + guint64 workspace_id; + guint64 pane_id; + guint64 tab_id; +} TabKindChangeRequest; + void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view) { g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); } @@ -267,15 +275,176 @@ static GtkWidget *create_new_tab_button(guint64 workspace_id, guint64 pane_id) { return button; } -static GtkWidget *create_tab_content(const char *kind) { - (void)kind; +static const char *tab_kind_or_blank(const char *kind) { + return kind == NULL || kind[0] == '\0' ? "blank" : kind; +} + +static const char *tab_kind_title(const char *kind) { + kind = tab_kind_or_blank(kind); + + if (g_strcmp0(kind, "fileTree") == 0) { + return "File Tree"; + } + if (g_strcmp0(kind, "editor") == 0) { + return "Editor"; + } + if (g_strcmp0(kind, "git") == 0) { + return "Git"; + } + if (g_strcmp0(kind, "search") == 0) { + return "Search"; + } + if (g_strcmp0(kind, "terminal") == 0) { + return "Terminal"; + } + if (g_strcmp0(kind, "settings") == 0) { + return "Settings"; + } + + return "Blank"; +} + +static void tab_kind_change_request_free(TabKindChangeRequest *request) { + g_clear_object(&request->window); + g_free(request); +} + +static void request_tab_kind_change(KosmosIpcTabKind kind, gpointer user_data) { + TabKindChangeRequest *request = user_data; + KosmosMainWindow *self = request->window; + + if (!kosmos_main_window_ensure_connected(self)) { + return; + } + + GError *error = NULL; + JsonNode *state = NULL; + kosmos_ipc_client_set_tab_kind( + self->ipc_client, + request->workspace_id, + request->pane_id, + request->tab_id, + kind, + &state, + NULL, + &error + ); + kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to change tab type"); + g_clear_error(&error); + if (state != NULL) { + json_node_unref(state); + } +} + +static GtkWidget *create_blank_tab_content( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id +) { + TabKindChangeRequest *request = g_new(TabKindChangeRequest, 1); + request->window = g_object_ref(self); + request->workspace_id = workspace_id; + request->pane_id = pane_id; + request->tab_id = tab_id; + + GtkWidget *content = kosmos_blank_tab_create(request_tab_kind_change, request); + g_object_set_data_full( + G_OBJECT(content), + "tab-kind-change-request", + request, + (GDestroyNotify)tab_kind_change_request_free + ); + + return content; +} + +static GtkWidget *create_unimplemented_tab_content(const char *kind) { + char *text = g_strdup_printf("%s tab content", tab_kind_title(kind)); GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *label = gtk_label_new(text); + + g_free(text); + gtk_widget_set_hexpand(content, TRUE); gtk_widget_set_vexpand(content, TRUE); + gtk_widget_set_halign(label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(label, GTK_ALIGN_CENTER); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_vexpand(label, TRUE); + gtk_box_append(GTK_BOX(content), label); + return content; } +static GtkWidget *create_tab_content_child( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + const char *kind +) { + if (g_strcmp0(tab_kind_or_blank(kind), "blank") == 0) { + return create_blank_tab_content(self, workspace_id, pane_id, tab_id); + } + + return create_unimplemented_tab_content(kind); +} + +static GtkWidget *create_tab_content( + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + const char *kind +) { + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + gtk_box_append(GTK_BOX(content), create_tab_content_child(self, workspace_id, pane_id, tab_id, kind)); + g_object_set_data_full(G_OBJECT(content), "tab-kind", g_strdup(tab_kind_or_blank(kind)), g_free); + + return content; +} + +static gboolean tab_content_needs_update(GtkWidget *content, const char *kind) { + const char *current_kind = g_object_get_data(G_OBJECT(content), "tab-kind"); + const char *next_kind = tab_kind_or_blank(kind); + + return g_strcmp0(next_kind, "blank") == 0 || g_strcmp0(current_kind, next_kind) != 0; +} + +static void clear_tab_content(GtkWidget *content) { + GtkWidget *child = gtk_widget_get_first_child(content); + + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(content), child); + child = next; + } +} + +static void update_tab_content( + AdwTabPage *page, + KosmosMainWindow *self, + guint64 workspace_id, + guint64 pane_id, + guint64 tab_id, + const char *kind +) { + GtkWidget *content = adw_tab_page_get_child(page); + + if (content == NULL || !GTK_IS_BOX(content) || !tab_content_needs_update(content, kind)) { + return; + } + + clear_tab_content(content); + gtk_box_append(GTK_BOX(content), create_tab_content_child(self, workspace_id, pane_id, tab_id, kind)); + g_object_set_data_full(G_OBJECT(content), "tab-kind", g_strdup(tab_kind_or_blank(kind)), g_free); +} + static void configure_tab_page(AdwTabPage *page, guint64 workspace_id, guint64 pane_id, guint64 tab_id, const char *title) { adw_tab_page_set_title(page, title); adw_tab_page_set_tooltip(page, title); @@ -402,7 +571,9 @@ gboolean kosmos_tabbed_pane_update_from_pane_view( return FALSE; } - page = adw_tab_view_append(view, create_tab_content(kind)); + page = adw_tab_view_append(view, create_tab_content(self, workspace_id, pane_id, tab_id, kind)); + } else { + update_tab_content(page, self, workspace_id, pane_id, tab_id, kind); } configure_tab_page(page, workspace_id, pane_id, tab_id, title); @@ -505,7 +676,7 @@ GtkWidget *kosmos_tabbed_pane_create(KosmosMainWindow *self, JsonObject *pane, g } kosmos_json_get_string_member(tab, "kind", &kind); - GtkWidget *content = create_tab_content(kind); + GtkWidget *content = create_tab_content(self, workspace_id, pane_id, tab_id, kind); AdwTabPage *page = adw_tab_view_append(tab_view, content); configure_tab_page(page, workspace_id, pane_id, tab_id, title); diff --git a/server/src/ipc/messages/tab.rs b/server/src/ipc/messages/tab.rs index 438c0ea7..62d0b081 100644 --- a/server/src/ipc/messages/tab.rs +++ b/server/src/ipc/messages/tab.rs @@ -20,6 +20,15 @@ pub(crate) struct ActivateTabParams { pub(crate) tab_id: TabIdParam, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SetTabKindParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) tab_id: TabIdParam, + pub(crate) kind: TabKindPayload, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CloseTabParams { diff --git a/server/src/ipc/router/tab.rs b/server/src/ipc/router/tab.rs index b7d21942..d358c651 100644 --- a/server/src/ipc/router/tab.rs +++ b/server/src/ipc/router/tab.rs @@ -1,6 +1,7 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; use super::super::messages::tab::{ - ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, SplitTabParams, + ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, SetTabKindParams, + SplitTabParams, }; use super::{command_response, parse_params, unsupported_action}; @@ -8,6 +9,7 @@ pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve match request.action.as_str() { "open" => open_tab(state, request), "activate" => activate_tab(state, request), + "setKind" => set_tab_kind(state, request), "close" => close_tab(state, request), "reorder" => reorder_tab(state, request), "split" => split_tab(state, request), @@ -50,6 +52,24 @@ fn activate_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMes } } +fn set_tab_kind(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.set_tab_kind( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.tab_id.into(), + params.kind.into(), + ), + state, + "tab.not_found", + "tab does not exist", + ), + Err(response) => response, + } +} + fn close_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match parse_params::(request) { Ok(params) => command_response( From c204c49d3e25e6f98b31a2eadc535542fd974b80 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 17:18:01 -0300 Subject: [PATCH 13/98] feat: add file tree tab --- core/src/file_tree.rs | 374 +++++++++++++++++ core/src/lib.rs | 1 + core/src/state.rs | 17 + desktop/meson.build | 2 + desktop/src/ipc/kosmos_ipc_client.h | 8 + desktop/src/ipc/kosmos_ipc_file_tree.c | 25 ++ desktop/src/ipc/kosmos_ipc_protocol.c | 2 + desktop/src/ipc/kosmos_ipc_protocol.h | 1 + desktop/src/tabs/kosmos_file_tree_tab.c | 428 ++++++++++++++++++++ desktop/src/tabs/kosmos_file_tree_tab.h | 11 + desktop/src/ui/kosmos_main_window_private.h | 1 + desktop/src/ui/kosmos_pane_dnd.c | 51 ++- desktop/src/ui/kosmos_tabbed_pane.c | 39 +- server/src/ipc/messages/envelope.rs | 1 + server/src/ipc/messages/file_tree.rs | 83 ++++ server/src/ipc/messages/mod.rs | 1 + server/src/ipc/router/file_tree.rs | 33 ++ server/src/ipc/router/mod.rs | 2 + 18 files changed, 1076 insertions(+), 4 deletions(-) create mode 100644 core/src/file_tree.rs create mode 100644 desktop/src/ipc/kosmos_ipc_file_tree.c create mode 100644 desktop/src/tabs/kosmos_file_tree_tab.c create mode 100644 desktop/src/tabs/kosmos_file_tree_tab.h create mode 100644 server/src/ipc/messages/file_tree.rs create mode 100644 server/src/ipc/router/file_tree.rs diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs new file mode 100644 index 00000000..06c37ed1 --- /dev/null +++ b/core/src/file_tree.rs @@ -0,0 +1,374 @@ +use std::error::Error as StdError; +use std::fmt; +use std::fs::{self, FileType}; +use std::io; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_FILE_TREE_MAX_DEPTH: usize = 6; +pub const DEFAULT_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY: usize = 500; +pub const MAX_FILE_TREE_MAX_DEPTH: usize = 16; +pub const MAX_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY: usize = 5_000; + +const DEFAULT_FILE_TREE_MAX_TOTAL_ENTRIES: usize = 10_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FileTreeOptions { + max_depth: usize, + max_entries_per_directory: usize, +} + +impl FileTreeOptions { + pub fn new(max_depth: usize, max_entries_per_directory: usize) -> Self { + Self { + max_depth: max_depth.min(MAX_FILE_TREE_MAX_DEPTH), + max_entries_per_directory: max_entries_per_directory + .clamp(1, MAX_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY), + } + } + + pub fn max_depth(self) -> usize { + self.max_depth + } + + pub fn max_entries_per_directory(self) -> usize { + self.max_entries_per_directory + } +} + +impl Default for FileTreeOptions { + fn default() -> Self { + Self { + max_depth: DEFAULT_FILE_TREE_MAX_DEPTH, + max_entries_per_directory: DEFAULT_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileTree { + root: FileTreeEntry, +} + +impl FileTree { + pub fn read(root: impl AsRef, options: FileTreeOptions) -> Result { + let root = root.as_ref().to_path_buf(); + let file_type = fs::symlink_metadata(&root) + .map_err(|error| FileTreeError::io(root.clone(), error))? + .file_type(); + let mut remaining_entries = DEFAULT_FILE_TREE_MAX_TOTAL_ENTRIES; + + Ok(Self { + root: read_entry(root, file_type, 0, options, &mut remaining_entries), + }) + } + + pub fn root(&self) -> &FileTreeEntry { + &self.root + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileTreeEntry { + name: String, + path: PathBuf, + kind: FileTreeEntryKind, + children: Vec, + children_truncated: bool, + read_error: Option, +} + +impl FileTreeEntry { + fn new(name: String, path: PathBuf, kind: FileTreeEntryKind) -> Self { + Self { + name, + path, + kind, + children: Vec::new(), + children_truncated: false, + read_error: None, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn kind(&self) -> FileTreeEntryKind { + self.kind + } + + pub fn children(&self) -> &[FileTreeEntry] { + &self.children + } + + pub fn children_truncated(&self) -> bool { + self.children_truncated + } + + pub fn read_error(&self) -> Option<&str> { + self.read_error.as_deref() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileTreeEntryKind { + Directory, + File, + Symlink, + Other, +} + +#[derive(Debug)] +pub enum FileTreeError { + WorkspaceUnavailable, + Io { path: PathBuf, source: io::Error }, +} + +impl FileTreeError { + fn io(path: PathBuf, source: io::Error) -> Self { + Self::Io { path, source } + } +} + +impl fmt::Display for FileTreeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WorkspaceUnavailable => formatter.write_str("workspace is not available"), + Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()), + } + } +} + +impl StdError for FileTreeError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::WorkspaceUnavailable => None, + Self::Io { source, .. } => Some(source), + } + } +} + +pub type Result = std::result::Result; + +fn read_entry( + path: PathBuf, + file_type: FileType, + depth: usize, + options: FileTreeOptions, + remaining_entries: &mut usize, +) -> FileTreeEntry { + let kind = entry_kind(file_type); + let name = entry_name(&path); + + read_entry_from_parts(path, name, kind, depth, options, remaining_entries) +} + +fn read_entry_from_parts( + path: PathBuf, + name: String, + kind: FileTreeEntryKind, + depth: usize, + options: FileTreeOptions, + remaining_entries: &mut usize, +) -> FileTreeEntry { + let mut entry = FileTreeEntry::new(name, path, kind); + + if kind != FileTreeEntryKind::Directory { + return entry; + } + + if depth >= options.max_depth() { + entry.children_truncated = true; + return entry; + } + + match read_children(entry.path(), depth + 1, options, remaining_entries) { + Ok((children, children_truncated)) => { + entry.children = children; + entry.children_truncated = children_truncated; + } + Err(error) => entry.read_error = Some(error.to_string()), + } + + entry +} + +fn read_children( + directory: &Path, + depth: usize, + options: FileTreeOptions, + remaining_entries: &mut usize, +) -> io::Result<(Vec, bool)> { + if *remaining_entries == 0 { + return Ok((Vec::new(), true)); + } + + let mut children = Vec::new(); + let mut child_entries = Vec::new(); + let mut children_truncated = false; + + for child in fs::read_dir(directory)? { + if child_entries.len() >= options.max_entries_per_directory() { + children_truncated = true; + break; + } + + let Ok(child) = child else { + continue; + }; + let Ok(file_type) = child.file_type() else { + continue; + }; + + let path = child.path(); + let kind = entry_kind(file_type); + child_entries.push(ChildEntry { + name: entry_name(&path), + path, + kind, + }); + } + + child_entries.sort_by(compare_child_entries); + + for child in child_entries { + if *remaining_entries == 0 { + children_truncated = true; + break; + } + + *remaining_entries -= 1; + let ChildEntry { name, path, kind } = child; + children.push(read_entry_from_parts( + path, + name, + kind, + depth, + options, + remaining_entries, + )); + } + + Ok((children, children_truncated)) +} + +struct ChildEntry { + name: String, + path: PathBuf, + kind: FileTreeEntryKind, +} + +fn compare_child_entries(first: &ChildEntry, second: &ChildEntry) -> std::cmp::Ordering { + compare_entry_parts(first.kind, &first.name, second.kind, &second.name) +} + +fn compare_entry_parts( + first_kind: FileTreeEntryKind, + first_name: &str, + second_kind: FileTreeEntryKind, + second_name: &str, +) -> std::cmp::Ordering { + first_kind + .sort_order() + .cmp(&second_kind.sort_order()) + .then_with(|| hidden_sort_order(first_name).cmp(&hidden_sort_order(second_name))) + .then_with(|| first_name.cmp(second_name)) +} + +fn hidden_sort_order(name: &str) -> u8 { + if name.starts_with('.') { 1 } else { 0 } +} + +fn entry_kind(file_type: FileType) -> FileTreeEntryKind { + if file_type.is_dir() { + FileTreeEntryKind::Directory + } else if file_type.is_file() { + FileTreeEntryKind::File + } else if file_type.is_symlink() { + FileTreeEntryKind::Symlink + } else { + FileTreeEntryKind::Other + } +} + +impl FileTreeEntryKind { + fn sort_order(self) -> u8 { + match self { + Self::Directory => 0, + Self::File => 1, + Self::Symlink => 2, + Self::Other => 3, + } + } +} + +fn entry_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn reads_directories_before_files() { + let root = test_directory("sorted"); + fs::create_dir_all(root.join("src")).expect("directory should be created"); + fs::write(root.join("README.md"), "readme").expect("file should be written"); + fs::write(root.join("src").join("main.rs"), "fn main() {}") + .expect("file should be written"); + + let tree = FileTree::read(&root, FileTreeOptions::new(2, 100)).expect("tree should read"); + + let names = tree + .root() + .children() + .iter() + .map(FileTreeEntry::name) + .collect::>(); + assert_eq!(names, vec!["src", "README.md"]); + assert_eq!(tree.root().children()[0].children()[0].name(), "main.rs"); + + remove_test_directory(root); + } + + #[test] + fn marks_directories_truncated_at_max_depth() { + let root = test_directory("depth"); + fs::create_dir_all(root.join("src").join("nested")).expect("directory should be created"); + + let tree = FileTree::read(&root, FileTreeOptions::new(1, 100)).expect("tree should read"); + let src = &tree.root().children()[0]; + + assert_eq!(src.name(), "src"); + assert!(src.children().is_empty()); + assert!(src.children_truncated()); + + remove_test_directory(root); + } + + fn test_directory(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kosmos-core-file-tree-{}-{name}-{nanos}", + std::process::id() + )); + + fs::create_dir_all(&directory).expect("test directory should be created"); + directory + } + + fn remove_test_directory(directory: PathBuf) { + let _ = fs::remove_dir_all(directory); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f5aaff37..ec1fd6a2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,6 @@ mod state; +pub mod file_tree; pub mod persistence; pub use state::State; pub mod tree; diff --git a/core/src/state.rs b/core/src/state.rs index ac2b25d8..65e52f99 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crate::file_tree::{FileTree, FileTreeError, FileTreeOptions}; use crate::tree::{ Pane, PaneId, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, @@ -45,6 +46,22 @@ impl State { &self.workspaces } + pub fn file_tree( + &self, + workspace_id: Option, + options: FileTreeOptions, + ) -> Result { + let workspace_id = self + .resolve_workspace_id(workspace_id) + .ok_or(FileTreeError::WorkspaceUnavailable)?; + let workspace = self + .workspaces + .workspace(workspace_id) + .ok_or(FileTreeError::WorkspaceUnavailable)?; + + FileTree::read(workspace.directory(), options) + } + pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { let workspace_id = self.next_workspace_id(); let initial_pane = self.blank_pane(); diff --git a/desktop/meson.build b/desktop/meson.build index d9475cd9..78d09f94 100644 --- a/desktop/meson.build +++ b/desktop/meson.build @@ -18,11 +18,13 @@ sources = files( 'src/main.c', 'src/app/kosmos_application.c', 'src/ipc/kosmos_ipc_client.c', + 'src/ipc/kosmos_ipc_file_tree.c', 'src/ipc/kosmos_ipc_pane.c', 'src/ipc/kosmos_ipc_protocol.c', 'src/ipc/kosmos_ipc_tab.c', 'src/ipc/kosmos_ipc_workspace.c', 'src/tabs/kosmos_blank_tab.c', + 'src/tabs/kosmos_file_tree_tab.c', 'src/ui/kosmos_main_window.c', 'src/ui/kosmos_pane_dnd.c', 'src/ui/kosmos_pane_layout.c', diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h index e51e5a5e..b3334274 100644 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -64,6 +64,14 @@ gboolean kosmos_ipc_client_activate_workspace( GError **error ); +gboolean kosmos_ipc_client_list_file_tree( + KosmosIpcClient *self, + guint64 workspace_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + gboolean kosmos_ipc_client_activate_pane( KosmosIpcClient *self, guint64 workspace_id, diff --git a/desktop/src/ipc/kosmos_ipc_file_tree.c b/desktop/src/ipc/kosmos_ipc_file_tree.c new file mode 100644 index 00000000..ba5aedae --- /dev/null +++ b/desktop/src/ipc/kosmos_ipc_file_tree.c @@ -0,0 +1,25 @@ +#include "ipc/kosmos_ipc_client.h" + +gboolean kosmos_ipc_client_list_file_tree( + KosmosIpcClient *self, + guint64 workspace_id, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_LIST, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.c b/desktop/src/ipc/kosmos_ipc_protocol.c index c7831c2f..57dd581c 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.c +++ b/desktop/src/ipc/kosmos_ipc_protocol.c @@ -8,6 +8,8 @@ const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain) { return "pane"; case KOSMOS_IPC_DOMAIN_TAB: return "tab"; + case KOSMOS_IPC_DOMAIN_FILE_TREE: + return "fileTree"; } g_return_val_if_reached("workspace"); diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h index 16541347..29bf900a 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -8,6 +8,7 @@ typedef enum { KOSMOS_IPC_DOMAIN_WORKSPACE, KOSMOS_IPC_DOMAIN_PANE, KOSMOS_IPC_DOMAIN_TAB, + KOSMOS_IPC_DOMAIN_FILE_TREE, } KosmosIpcDomain; typedef enum { diff --git a/desktop/src/tabs/kosmos_file_tree_tab.c b/desktop/src/tabs/kosmos_file_tree_tab.c new file mode 100644 index 00000000..f8d90093 --- /dev/null +++ b/desktop/src/tabs/kosmos_file_tree_tab.c @@ -0,0 +1,428 @@ +#include "tabs/kosmos_file_tree_tab.h" + +#define KOSMOS_TYPE_FILE_TREE_ITEM (kosmos_file_tree_item_get_type()) +#define KOSMOS_FILE_TREE_ITEM(object) (G_TYPE_CHECK_INSTANCE_CAST((object), KOSMOS_TYPE_FILE_TREE_ITEM, KosmosFileTreeItem)) + +typedef struct _KosmosFileTreeItem KosmosFileTreeItem; +typedef struct _KosmosFileTreeItemClass KosmosFileTreeItemClass; + +struct _KosmosFileTreeItem { + GObject parent_instance; + char *name; + char *path; + char *kind; + GListStore *children; +}; + +struct _KosmosFileTreeItemClass { + GObjectClass parent_class; +}; + +GType kosmos_file_tree_item_get_type(void); +G_DEFINE_FINAL_TYPE(KosmosFileTreeItem, kosmos_file_tree_item, G_TYPE_OBJECT) + +static void kosmos_file_tree_item_finalize(GObject *object) { + KosmosFileTreeItem *self = KOSMOS_FILE_TREE_ITEM(object); + + g_clear_pointer(&self->name, g_free); + g_clear_pointer(&self->path, g_free); + g_clear_pointer(&self->kind, g_free); + g_clear_object(&self->children); + + G_OBJECT_CLASS(kosmos_file_tree_item_parent_class)->finalize(object); +} + +static void kosmos_file_tree_item_class_init(KosmosFileTreeItemClass *klass) { + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->finalize = kosmos_file_tree_item_finalize; +} + +static void kosmos_file_tree_item_init(KosmosFileTreeItem *self) { + self->children = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); +} + +static KosmosFileTreeItem *kosmos_file_tree_item_new(const char *name, const char *path, const char *kind) { + KosmosFileTreeItem *item = g_object_new(KOSMOS_TYPE_FILE_TREE_ITEM, NULL); + + item->name = g_strdup(name == NULL || name[0] == '\0' ? "Untitled" : name); + item->path = g_strdup(path == NULL ? "" : path); + item->kind = g_strdup(kind == NULL || kind[0] == '\0' ? "other" : kind); + + return item; +} + +static KosmosFileTreeItem *kosmos_file_tree_item_new_message(const char *message) { + return kosmos_file_tree_item_new(message, "", "message"); +} + +static gboolean kosmos_file_tree_item_is_directory(KosmosFileTreeItem *item) { + return g_strcmp0(item->kind, "directory") == 0; +} + +static gboolean kosmos_file_tree_item_is_message(KosmosFileTreeItem *item) { + return g_strcmp0(item->kind, "message") == 0; +} + +static guint kosmos_file_tree_item_child_count(KosmosFileTreeItem *item) { + return g_list_model_get_n_items(G_LIST_MODEL(item->children)); +} + +static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { + return FALSE; + } + + *value = json_node_get_string(node); + return TRUE; +} + +static gboolean get_bool_member(JsonObject *object, const char *name, gboolean *value) { + JsonNode *node = json_object_get_member(object, name); + if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_BOOLEAN) { + return FALSE; + } + + *value = json_node_get_boolean(node); + return TRUE; +} + +static JsonObject *get_object_member(JsonObject *object, const char *name) { + JsonNode *node = json_object_get_member(object, name); + + if (node == NULL || !JSON_NODE_HOLDS_OBJECT(node)) { + return NULL; + } + + return json_node_get_object(node); +} + +static JsonArray *get_array_member(JsonObject *object, const char *name) { + JsonNode *node = json_object_get_member(object, name); + + if (node == NULL || !JSON_NODE_HOLDS_ARRAY(node)) { + return NULL; + } + + return json_node_get_array(node); +} + +static GtkWidget *create_status_label(const char *text, const char *css_class) { + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *label = gtk_label_new(text); + + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.5f); + gtk_widget_set_halign(label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(label, GTK_ALIGN_CENTER); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_vexpand(label, TRUE); + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + + if (css_class != NULL) { + gtk_widget_add_css_class(label, css_class); + } + + gtk_box_append(GTK_BOX(content), label); + return content; +} + +static const char *entry_icon_name(KosmosFileTreeItem *item) { + if (kosmos_file_tree_item_is_directory(item)) { + return "folder-symbolic"; + } + if (kosmos_file_tree_item_is_message(item)) { + return "dialog-information-symbolic"; + } + if (g_strcmp0(item->kind, "symlink") == 0) { + return "emblem-symbolic-link-symbolic"; + } + + return "text-x-generic-symbolic"; +} + +static void append_message_child(KosmosFileTreeItem *item, const char *message) { + KosmosFileTreeItem *message_item = kosmos_file_tree_item_new_message(message); + g_list_store_append(item->children, message_item); + g_object_unref(message_item); +} + +static KosmosFileTreeItem *file_tree_item_from_entry(JsonObject *entry) { + const char *name = NULL; + const char *path = NULL; + const char *kind = NULL; + const char *read_error = NULL; + gboolean children_truncated = FALSE; + + if (!get_string_member(entry, "name", &name) || !get_string_member(entry, "kind", &kind)) { + return kosmos_file_tree_item_new_message("Invalid file tree entry."); + } + + get_string_member(entry, "path", &path); + get_string_member(entry, "readError", &read_error); + get_bool_member(entry, "childrenTruncated", &children_truncated); + + KosmosFileTreeItem *item = kosmos_file_tree_item_new(name, path, kind); + + JsonArray *children = get_array_member(entry, "children"); + guint child_count = children == NULL ? 0 : json_array_get_length(children); + for (guint index = 0; index < child_count; index++) { + JsonNode *child_node = json_array_get_element(children, index); + if (child_node == NULL || !JSON_NODE_HOLDS_OBJECT(child_node)) { + append_message_child(item, "Invalid file tree entry."); + continue; + } + + KosmosFileTreeItem *child = file_tree_item_from_entry(json_node_get_object(child_node)); + g_list_store_append(item->children, child); + g_object_unref(child); + } + + if (children_truncated) { + append_message_child(item, "Directory listing is truncated."); + } + if (read_error != NULL) { + append_message_child(item, read_error); + } + + return item; +} + +static GListModel *create_child_model(gpointer item, gpointer user_data) { + (void)user_data; + + KosmosFileTreeItem *entry = KOSMOS_FILE_TREE_ITEM(item); + if (kosmos_file_tree_item_child_count(entry) == 0) { + return NULL; + } + + return G_LIST_MODEL(g_object_ref(entry->children)); +} + +static void setup_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + (void)user_data; + + GtkWidget *hit_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *icon = gtk_image_new(); + GtkWidget *label = gtk_label_new(NULL); + + gtk_list_item_set_selectable(list_item, FALSE); + gtk_widget_set_margin_top(row, 3); + gtk_widget_set_margin_bottom(row, 3); + gtk_widget_set_margin_end(row, 10); + gtk_widget_set_hexpand(hit_row, TRUE); + gtk_widget_set_halign(hit_row, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(row, TRUE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(label, TRUE); + + gtk_box_append(GTK_BOX(row), icon); + gtk_box_append(GTK_BOX(row), label); + gtk_box_append(GTK_BOX(hit_row), row); + g_object_set_data(G_OBJECT(hit_row), "entry-row", row); + g_object_set_data(G_OBJECT(hit_row), "entry-icon", icon); + g_object_set_data(G_OBJECT(hit_row), "entry-label", label); + + gtk_list_item_set_child(list_item, hit_row); +} + +static void set_tree_row_data(GtkWidget *widget, GtkTreeListRow *tree_row) { + g_object_set_data_full(G_OBJECT(widget), "tree-row", g_object_ref(tree_row), g_object_unref); +} + +static GtkTreeListRow *tree_row_data(GtkWidget *widget) { + GtkTreeListRow *tree_row = g_object_get_data(G_OBJECT(widget), "tree-row"); + + return tree_row == NULL ? NULL : g_object_ref(tree_row); +} + +static void bind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + (void)user_data; + + GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item); + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); + GtkWidget *hit_row = gtk_list_item_get_child(list_item); + GtkWidget *row = g_object_get_data(G_OBJECT(hit_row), "entry-row"); + GtkWidget *icon = g_object_get_data(G_OBJECT(hit_row), "entry-icon"); + GtkWidget *label = g_object_get_data(G_OBJECT(hit_row), "entry-label"); + guint depth = gtk_tree_list_row_get_depth(tree_row); + + gtk_widget_set_margin_start(row, 10 + (int)(depth * 18)); + gtk_image_set_from_icon_name(GTK_IMAGE(icon), entry_icon_name(item)); + gtk_label_set_text(GTK_LABEL(label), item->name); + gtk_widget_set_tooltip_text(hit_row, item->path[0] == '\0' ? NULL : item->path); + set_tree_row_data(hit_row, tree_row); + + if (kosmos_file_tree_item_is_message(item)) { + gtk_widget_add_css_class(label, "dim-label"); + gtk_widget_add_css_class(icon, "dim-label"); + } else { + gtk_widget_remove_css_class(label, "dim-label"); + gtk_widget_remove_css_class(icon, "dim-label"); + } +} + +static GtkTreeListRow *tree_row_in_descendants(GtkWidget *widget) { + if (widget == NULL) { + return NULL; + } + + GtkTreeListRow *tree_row = tree_row_data(widget); + if (tree_row != NULL) { + return tree_row; + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkTreeListRow *child_row = tree_row_in_descendants(child); + if (child_row != NULL) { + return child_row; + } + } + + return NULL; +} + +static GtkTreeListRow *tree_row_in_ancestors(GtkWidget *widget) { + for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { + GtkTreeListRow *tree_row = tree_row_data(ancestor); + if (tree_row != NULL) { + return tree_row; + } + } + + return NULL; +} + +static GtkTreeListRow *tree_row_for_picked_widget(GtkWidget *widget) { + GtkTreeListRow *tree_row = tree_row_in_ancestors(widget); + + return tree_row == NULL ? tree_row_in_descendants(widget) : tree_row; +} + +static void file_tree_clicked(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)n_press; + + GtkWidget *root = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + GtkWidget *list = GTK_WIDGET(user_data); + graphene_point_t root_point = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + GtkTreeListRow *tree_row = NULL; + + if (gtk_widget_compute_point(root, list, &root_point, &list_point)) { + GtkWidget *picked = gtk_widget_pick(list, list_point.x, list_point.y, GTK_PICK_DEFAULT); + tree_row = tree_row_for_picked_widget(picked); + } + + if (tree_row != NULL && gtk_tree_list_row_is_expandable(tree_row)) { + gtk_tree_list_row_set_expanded(tree_row, !gtk_tree_list_row_get_expanded(tree_row)); + } + + g_clear_object(&tree_row); +} + +static void unbind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + (void)user_data; + + GtkWidget *hit_row = gtk_list_item_get_child(list_item); + if (hit_row != NULL) { + g_object_set_data(G_OBJECT(hit_row), "tree-row", NULL); + } +} + +static GtkWidget *create_file_tree_list(JsonObject *root) { + GListStore *root_store = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); + KosmosFileTreeItem *root_item = file_tree_item_from_entry(root); + g_list_store_append(root_store, root_item); + g_object_unref(root_item); + + GtkTreeListModel *tree_model = gtk_tree_list_model_new( + G_LIST_MODEL(root_store), + FALSE, + FALSE, + create_child_model, + NULL, + NULL + ); + GtkTreeListRow *root_row = g_list_model_get_item(G_LIST_MODEL(tree_model), 0); + if (root_row != NULL) { + gtk_tree_list_row_set_expanded(root_row, TRUE); + g_object_unref(root_row); + } + + GtkListItemFactory *factory = gtk_signal_list_item_factory_new(); + g_signal_connect(factory, "setup", G_CALLBACK(setup_list_item), NULL); + g_signal_connect(factory, "bind", G_CALLBACK(bind_list_item), NULL); + g_signal_connect(factory, "unbind", G_CALLBACK(unbind_list_item), NULL); + + GtkSelectionModel *selection = GTK_SELECTION_MODEL(gtk_no_selection_new(G_LIST_MODEL(tree_model))); + GtkWidget *list = gtk_list_view_new(selection, factory); + + gtk_widget_set_margin_bottom(list, 8); + gtk_widget_set_vexpand(list, TRUE); + gtk_widget_set_hexpand(list, TRUE); + + return list; +} + +static GtkWidget *create_tree_view(JsonNode *tree) { + if (tree == NULL || !JSON_NODE_HOLDS_OBJECT(tree)) { + return create_status_label("File tree is unavailable.", "dim-label"); + } + + JsonObject *result = json_node_get_object(tree); + JsonObject *root = get_object_member(result, "root"); + if (root == NULL) { + return create_status_label("File tree response is invalid.", "error"); + } + + GtkWidget *root_widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *scrolled = gtk_scrolled_window_new(); + GtkWidget *list = create_file_tree_list(root); + GtkGesture *click = gtk_gesture_click_new(); + + gtk_widget_set_hexpand(root_widget, TRUE); + gtk_widget_set_vexpand(root_widget, TRUE); + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); + g_signal_connect(click, "pressed", G_CALLBACK(file_tree_clicked), list); + gtk_widget_add_controller(root_widget, GTK_EVENT_CONTROLLER(click)); + + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), list); + gtk_widget_set_hexpand(scrolled, TRUE); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_box_append(GTK_BOX(root_widget), scrolled); + + return root_widget; +} + +GtkWidget *kosmos_file_tree_tab_create(KosmosIpcClient *ipc_client, guint64 workspace_id) { + g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(ipc_client), NULL); + + GError *error = NULL; + JsonNode *tree = NULL; + if (!kosmos_ipc_client_list_file_tree(ipc_client, workspace_id, &tree, NULL, &error)) { + const char *detail = error == NULL ? "unknown IPC error" : error->message; + char *message = g_strdup_printf("Failed to load file tree: %s", detail); + GtkWidget *status = create_status_label(message, "error"); + + g_free(message); + g_clear_error(&error); + return status; + } + + GtkWidget *view = create_tree_view(tree); + if (tree != NULL) { + json_node_unref(tree); + } + + return view; +} diff --git a/desktop/src/tabs/kosmos_file_tree_tab.h b/desktop/src/tabs/kosmos_file_tree_tab.h new file mode 100644 index 00000000..1db32d6e --- /dev/null +++ b/desktop/src/tabs/kosmos_file_tree_tab.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "ipc/kosmos_ipc_client.h" + +G_BEGIN_DECLS + +GtkWidget *kosmos_file_tree_tab_create(KosmosIpcClient *ipc_client, guint64 workspace_id); + +G_END_DECLS diff --git a/desktop/src/ui/kosmos_main_window_private.h b/desktop/src/ui/kosmos_main_window_private.h index ec275053..0bb4f7c4 100644 --- a/desktop/src/ui/kosmos_main_window_private.h +++ b/desktop/src/ui/kosmos_main_window_private.h @@ -96,6 +96,7 @@ gboolean kosmos_tabbed_pane_update_from_pane_view( void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view); void kosmos_pane_dnd_install_css(GtkWidget *widget); +void kosmos_pane_dnd_set_split_targets_enabled(KosmosMainWindow *self, gboolean enabled); GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id); AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data); void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self); diff --git a/desktop/src/ui/kosmos_pane_dnd.c b/desktop/src/ui/kosmos_pane_dnd.c index 23a920ca..e0e57cb8 100644 --- a/desktop/src/ui/kosmos_pane_dnd.c +++ b/desktop/src/ui/kosmos_pane_dnd.c @@ -64,6 +64,8 @@ static void detached_tab_split_free(DetachedTabSplit *split) { } void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self) { + self->splitting_detached_tab = FALSE; + if (self->detached_tab_window != NULL) { gtk_window_destroy(self->detached_tab_window); } @@ -162,6 +164,10 @@ static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, (void)position; KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); + if (!ADW_IS_TAB_VIEW(view) || !ADW_IS_TAB_PAGE(page)) { + return; + } + gboolean is_split_target = g_object_get_data(G_OBJECT(view), "split-drop-target") != NULL; if (!self->splitting_detached_tab && !is_split_target) { @@ -201,7 +207,7 @@ static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, split->tab_id = tab_id; split->axis = axis; split->new_pane_first = new_pane_first; - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); + g_timeout_add_full(G_PRIORITY_DEFAULT, 16, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); } AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data) { @@ -259,6 +265,43 @@ static void set_pane_drag_highlight(KosmosMainWindow *self, GtkWidget *zone) { g_object_set_data(G_OBJECT(self), "pane-drag-highlight-zone", zone); } +static void set_split_drop_target_enabled(GtkWidget *target, gboolean enabled) { + GtkWidget *drop_receiver = g_object_get_data(G_OBJECT(target), "split-drop-receiver"); + GtkWidget *tab_view = g_object_get_data(G_OBJECT(target), "split-drop-tab-view"); + + gtk_widget_set_visible(target, enabled); + gtk_widget_set_can_target(target, enabled); + if (drop_receiver != NULL) { + gtk_widget_set_can_target(drop_receiver, enabled); + } + if (tab_view != NULL) { + gtk_widget_set_can_target(tab_view, enabled); + } +} + +static void set_split_targets_enabled_in(GtkWidget *widget, gboolean enabled) { + if (widget == NULL) { + return; + } + + if (g_object_get_data(G_OBJECT(widget), "split-drop-zone") != NULL) { + set_split_drop_target_enabled(widget, enabled); + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + set_split_targets_enabled_in(child, enabled); + } +} + +void kosmos_pane_dnd_set_split_targets_enabled(KosmosMainWindow *self, gboolean enabled) { + if (!enabled) { + set_pane_drag_highlight(self, NULL); + } + + set_split_targets_enabled_in(self->content_area, enabled); + set_split_targets_enabled_in(self->staged_content_area, enabled); +} + static gboolean split_zone_contains_point(GtkWidget *zone, GtkWidget *source, double source_x, double source_y) { graphene_point_t source_point = GRAPHENE_POINT_INIT((float)source_x, (float)source_y); graphene_point_t zone_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); @@ -459,6 +502,7 @@ static void pane_drag_update(GtkGestureDrag *gesture, double offset_x, double of drag->active = TRUE; kosmos_tabbed_pane_clear_pending_activation(drag->view); g_object_set_data(G_OBJECT(drag->view), "tab-press-active", NULL); + kosmos_pane_dnd_set_split_targets_enabled(drag->window, TRUE); show_pane_drag_preview(drag); gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); } @@ -486,6 +530,7 @@ static void pane_drag_end(GtkGestureDrag *gesture, double offset_x, double offse drag->active = FALSE; set_pane_drag_highlight(drag->window, NULL); clear_pane_drag_preview(drag); + kosmos_pane_dnd_set_split_targets_enabled(drag->window, FALSE); if (!has_zone) { return; } @@ -634,6 +679,8 @@ static GtkWidget *create_split_drop_target( zone->axis = axis; zone->new_pane_first = new_pane_first; g_object_set_data_full(G_OBJECT(target), "split-drop-zone", zone, g_free); + g_object_set_data(G_OBJECT(target), "split-drop-receiver", drop_receiver); + g_object_set_data(G_OBJECT(target), "split-drop-tab-view", GTK_WIDGET(tab_view)); g_object_set_data(G_OBJECT(target), "split-drop-highlight", highlight); g_object_set_data(G_OBJECT(motion), "split-drop-highlight", highlight); @@ -641,6 +688,7 @@ static GtkWidget *create_split_drop_target( g_signal_connect(motion, "motion", G_CALLBACK(show_split_drop_highlight), NULL); g_signal_connect(motion, "leave", G_CALLBACK(hide_split_drop_highlight), NULL); gtk_widget_add_controller(target, motion); + set_split_drop_target_enabled(target, FALSE); return target; } @@ -657,6 +705,7 @@ static void add_split_drop_zone( gtk_widget_set_valign(target, valign); gtk_widget_set_size_request(target, width, height); gtk_overlay_add_overlay(overlay, target); + set_split_drop_target_enabled(target, FALSE); } GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id) { diff --git a/desktop/src/ui/kosmos_tabbed_pane.c b/desktop/src/ui/kosmos_tabbed_pane.c index 6bb68b47..4a3902a4 100644 --- a/desktop/src/ui/kosmos_tabbed_pane.c +++ b/desktop/src/ui/kosmos_tabbed_pane.c @@ -1,5 +1,6 @@ #include "ui/kosmos_main_window_private.h" #include "tabs/kosmos_blank_tab.h" +#include "tabs/kosmos_file_tree_tab.h" typedef struct { guint64 workspace_id; @@ -24,6 +25,11 @@ typedef struct { guint64 tab_id; } TabKindChangeRequest; +typedef struct { + KosmosMainWindow *window; + AdwTabView *view; +} DeferredSplitTargetDisable; + void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view) { g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); } @@ -69,6 +75,22 @@ static void deferred_tab_activation_free(DeferredTabActivation *activation) { g_free(activation); } +static void deferred_split_target_disable_free(DeferredSplitTargetDisable *disable) { + g_object_unref(disable->window); + g_object_unref(disable->view); + g_free(disable); +} + +static gboolean finish_split_target_disable(gpointer user_data) { + DeferredSplitTargetDisable *disable = user_data; + + if (!adw_tab_view_get_is_transferring_page(disable->view) && !disable->window->splitting_detached_tab) { + kosmos_pane_dnd_set_split_targets_enabled(disable->window, FALSE); + } + + return G_SOURCE_REMOVE; +} + static gboolean finish_pending_tab_activation(gpointer user_data) { DeferredTabActivation *activation = user_data; AdwTabView *view = activation->view; @@ -111,11 +133,19 @@ static void tab_bar_released(GtkGestureClick *gesture, int n_press, double x, do static void tab_transfer_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { (void)pspec; - (void)user_data; + KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); AdwTabView *view = ADW_TAB_VIEW(object); - if (adw_tab_view_get_is_transferring_page(view)) { + gboolean transferring = adw_tab_view_get_is_transferring_page(view); + + if (transferring) { + kosmos_pane_dnd_set_split_targets_enabled(self, TRUE); kosmos_tabbed_pane_clear_pending_activation(view); + } else { + DeferredSplitTargetDisable *disable = g_new(DeferredSplitTargetDisable, 1); + disable->window = g_object_ref(self); + disable->view = g_object_ref(view); + g_timeout_add_full(G_PRIORITY_DEFAULT, 16, finish_split_target_disable, disable, (GDestroyNotify)deferred_split_target_disable_free); } } @@ -388,6 +418,9 @@ static GtkWidget *create_tab_content_child( if (g_strcmp0(tab_kind_or_blank(kind), "blank") == 0) { return create_blank_tab_content(self, workspace_id, pane_id, tab_id); } + if (g_strcmp0(tab_kind_or_blank(kind), "fileTree") == 0) { + return kosmos_file_tree_tab_create(self->ipc_client, workspace_id); + } return create_unimplemented_tab_content(kind); } @@ -711,7 +744,7 @@ GtkWidget *kosmos_tabbed_pane_create(KosmosMainWindow *self, JsonObject *pane, g gtk_box_append(GTK_BOX(container), GTK_WIDGET(tab_bar)); gtk_box_append(GTK_BOX(container), kosmos_pane_dnd_create_split_overlay(self, tab_view, pane_id)); g_signal_connect(tab_view, "notify::selected-page", G_CALLBACK(activate_selected_tab), self); - g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), NULL); + g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), self); g_signal_connect(tab_view, "close-page", G_CALLBACK(close_tab), self); g_signal_connect(tab_view, "create-window", G_CALLBACK(kosmos_pane_dnd_create_split_sink_for_detached_tab), self); g_signal_connect(tab_view, "page-detached", G_CALLBACK(tab_detached), NULL); diff --git a/server/src/ipc/messages/envelope.rs b/server/src/ipc/messages/envelope.rs index dad01c1f..5a449b15 100644 --- a/server/src/ipc/messages/envelope.rs +++ b/server/src/ipc/messages/envelope.rs @@ -62,6 +62,7 @@ pub(crate) enum Domain { Workspace, Pane, Tab, + FileTree, } #[derive(Debug, Serialize)] diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs new file mode 100644 index 00000000..4b2a818b --- /dev/null +++ b/server/src/ipc/messages/file_tree.rs @@ -0,0 +1,83 @@ +use core::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind, FileTreeOptions}; +use serde::{Deserialize, Serialize}; + +use super::pane::WorkspaceIdParam; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileTreeParams { + pub(crate) workspace_id: Option, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl FileTreeParams { + pub(crate) fn options(&self) -> FileTreeOptions { + let defaults = FileTreeOptions::default(); + + FileTreeOptions::new( + self.max_depth.unwrap_or(defaults.max_depth()), + self.max_entries_per_directory + .unwrap_or(defaults.max_entries_per_directory()), + ) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileTreeSnapshot { + root: FileTreeEntrySnapshot, +} + +impl FileTreeSnapshot { + pub(crate) fn from_tree(tree: &FileTree) -> Self { + Self { + root: FileTreeEntrySnapshot::from_entry(tree.root()), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeEntrySnapshot { + name: String, + path: String, + kind: FileTreeEntryKindPayload, + children: Vec, + children_truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + read_error: Option, +} + +impl FileTreeEntrySnapshot { + fn from_entry(entry: &FileTreeEntry) -> Self { + Self { + name: entry.name().to_owned(), + path: entry.path().to_string_lossy().into_owned(), + kind: entry.kind().into(), + children: entry.children().iter().map(Self::from_entry).collect(), + children_truncated: entry.children_truncated(), + read_error: entry.read_error().map(str::to_owned), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +enum FileTreeEntryKindPayload { + Directory, + File, + Symlink, + Other, +} + +impl From for FileTreeEntryKindPayload { + fn from(value: FileTreeEntryKind) -> Self { + match value { + FileTreeEntryKind::Directory => Self::Directory, + FileTreeEntryKind::File => Self::File, + FileTreeEntryKind::Symlink => Self::Symlink, + FileTreeEntryKind::Other => Self::Other, + } + } +} diff --git a/server/src/ipc/messages/mod.rs b/server/src/ipc/messages/mod.rs index 3e35c43e..c63601b2 100644 --- a/server/src/ipc/messages/mod.rs +++ b/server/src/ipc/messages/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod envelope; +pub(crate) mod file_tree; pub(crate) mod pane; pub(crate) mod tab; pub(crate) mod workspace; diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs new file mode 100644 index 00000000..a711cb06 --- /dev/null +++ b/server/src/ipc/router/file_tree.rs @@ -0,0 +1,33 @@ +use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; +use super::super::messages::file_tree::{FileTreeParams, FileTreeSnapshot}; +use super::{parse_params, unsupported_action}; + +pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match request.action.as_str() { + "list" => list_file_tree(state, request), + _ => unsupported_action(request), + } +} + +fn list_file_tree(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + match state.file_tree(params.workspace_id.map(Into::into), params.options()) { + Ok(tree) => ServerMessage::ok(request.id, FileTreeSnapshot::from_tree(&tree)), + Err(error) => ServerMessage::error( + request.id, + file_tree_error_code(&error), + error.to_string(), + ), + } + } + Err(response) => response, + } +} + +fn file_tree_error_code(error: &core::file_tree::FileTreeError) -> &'static str { + match error { + core::file_tree::FileTreeError::WorkspaceUnavailable => "workspace.not_found", + core::file_tree::FileTreeError::Io { .. } => "file_tree.read_failed", + } +} diff --git a/server/src/ipc/router/mod.rs b/server/src/ipc/router/mod.rs index 8a1a1988..6d896d97 100644 --- a/server/src/ipc/router/mod.rs +++ b/server/src/ipc/router/mod.rs @@ -1,3 +1,4 @@ +mod file_tree; mod pane; mod tab; mod workspace; @@ -12,6 +13,7 @@ pub(crate) fn route(state: &mut core::State, request: RequestEnvelope) -> Server Domain::Workspace => workspace::route(state, &request), Domain::Pane => pane::route(state, &request), Domain::Tab => tab::route(state, &request), + Domain::FileTree => file_tree::route(state, &request), } } From 6487acfb9f2fc9acdeb7cddd9739c5dd614be80d Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 15 Jun 2026 17:22:18 -0300 Subject: [PATCH 14/98] fix: ignore empty file tree clicks --- desktop/src/tabs/kosmos_file_tree_tab.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/desktop/src/tabs/kosmos_file_tree_tab.c b/desktop/src/tabs/kosmos_file_tree_tab.c index f8d90093..b73614a5 100644 --- a/desktop/src/tabs/kosmos_file_tree_tab.c +++ b/desktop/src/tabs/kosmos_file_tree_tab.c @@ -300,6 +300,19 @@ static GtkTreeListRow *tree_row_in_ancestors(GtkWidget *widget) { } static GtkTreeListRow *tree_row_for_picked_widget(GtkWidget *widget) { + gboolean picked_list_item = FALSE; + + for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { + if (g_strcmp0(G_OBJECT_TYPE_NAME(ancestor), "GtkListItemWidget") == 0) { + picked_list_item = TRUE; + break; + } + } + + if (!picked_list_item) { + return NULL; + } + GtkTreeListRow *tree_row = tree_row_in_ancestors(widget); return tree_row == NULL ? tree_row_in_descendants(widget) : tree_row; From 4ce2dfd3614c5512cd407f10215b733addc006a8 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Thu, 18 Jun 2026 19:57:57 -0300 Subject: [PATCH 15/98] feat: add file tree mutations --- core/src/file_tree.rs | 581 +++++++ core/src/state.rs | 88 +- desktop/src/ipc/kosmos_ipc_client.c | 12 +- desktop/src/ipc/kosmos_ipc_client.h | 50 + desktop/src/ipc/kosmos_ipc_file_tree.c | 154 ++ desktop/src/ipc/kosmos_ipc_protocol.h | 4 + desktop/src/tabs/kosmos_file_tree_tab.c | 2118 ++++++++++++++++++++--- server/src/ipc/messages/file_tree.rs | 109 +- server/src/ipc/router/file_tree.rs | 120 +- 9 files changed, 3022 insertions(+), 214 deletions(-) diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs index 06c37ed1..70407708 100644 --- a/core/src/file_tree.rs +++ b/core/src/file_tree.rs @@ -67,6 +67,117 @@ impl FileTree { } } +pub fn create_entry( + root: impl AsRef, + parent: impl AsRef, + name: &str, + kind: FileTreeCreateKind, +) -> Result { + validate_entry_name(name)?; + + let root = canonical_workspace_root(root.as_ref())?; + let parent = resolve_existing_directory(&root, parent.as_ref())?; + let target = parent.join(name); + + if path_exists(&target) { + return Err(FileTreeError::AlreadyExists(target)); + } + + match kind { + FileTreeCreateKind::File => fs::File::create(&target) + .map(|_| ()) + .map_err(|error| FileTreeError::io(target.clone(), error))?, + FileTreeCreateKind::Directory => { + fs::create_dir(&target).map_err(|error| FileTreeError::io(target.clone(), error))? + } + } + + Ok(target) +} + +pub fn rename_entry(root: impl AsRef, path: impl AsRef, name: &str) -> Result { + validate_entry_name(name)?; + + let root = canonical_workspace_root(root.as_ref())?; + let source = resolve_existing_entry(&root, path.as_ref())?; + reject_workspace_root_operation(&root, &source)?; + let target = source + .parent() + .expect("resolved entries must have a parent") + .join(name); + + if path_exists(&target) { + return Err(FileTreeError::AlreadyExists(target)); + } + + fs::rename(&source, &target).map_err(|error| FileTreeError::io(source, error))?; + Ok(target) +} + +pub fn delete_entry(root: impl AsRef, path: impl AsRef) -> Result<()> { + let root = canonical_workspace_root(root.as_ref())?; + let path = resolve_existing_entry(&root, path.as_ref())?; + reject_workspace_root_operation(&root, &path)?; + let file_type = fs::symlink_metadata(&path) + .map_err(|error| FileTreeError::io(path.clone(), error))? + .file_type(); + + if file_type.is_dir() { + fs::remove_dir_all(&path).map_err(|error| FileTreeError::io(path, error)) + } else { + fs::remove_file(&path).map_err(|error| FileTreeError::io(path, error)) + } +} + +pub fn move_entry( + root: impl AsRef, + path: impl AsRef, + target_directory: impl AsRef, +) -> Result { + let root = canonical_workspace_root(root.as_ref())?; + let source = resolve_existing_entry(&root, path.as_ref())?; + reject_workspace_root_operation(&root, &source)?; + let target_directory = resolve_existing_directory(&root, target_directory.as_ref())?; + let target = target_directory.join( + source + .file_name() + .ok_or_else(|| FileTreeError::InvalidPath(source.clone()))?, + ); + + if source == target { + return Ok(source); + } + if source_is_directory(&source)? && target_directory.starts_with(&source) { + return Err(FileTreeError::InvalidMove); + } + if path_exists(&target) { + return Err(FileTreeError::AlreadyExists(target)); + } + + fs::rename(&source, &target).map_err(|error| FileTreeError::io(source, error))?; + Ok(target) +} + +pub fn copy_entry( + root: impl AsRef, + path: impl AsRef, + target_directory: impl AsRef, +) -> Result { + let root = canonical_workspace_root(root.as_ref())?; + let source = resolve_existing_entry(&root, path.as_ref())?; + reject_workspace_root_operation(&root, &source)?; + let target_directory = resolve_existing_directory(&root, target_directory.as_ref())?; + + if source_is_directory(&source)? && target_directory.starts_with(&source) { + return Err(FileTreeError::InvalidCopy); + } + + let target = available_copy_target(&source, &target_directory)?; + + copy_entry_to(&source, &target)?; + Ok(target) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct FileTreeEntry { name: String, @@ -122,10 +233,24 @@ pub enum FileTreeEntryKind { Other, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileTreeCreateKind { + File, + Directory, +} + #[derive(Debug)] pub enum FileTreeError { WorkspaceUnavailable, + AlreadyExists(PathBuf), + InvalidCopy, + InvalidMove, + InvalidName(String), + InvalidPath(PathBuf), Io { path: PathBuf, source: io::Error }, + NotDirectory(PathBuf), + OutsideWorkspace(PathBuf), + RootOperation, } impl FileTreeError { @@ -138,7 +263,17 @@ impl fmt::Display for FileTreeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::WorkspaceUnavailable => formatter.write_str("workspace is not available"), + Self::AlreadyExists(path) => write!(formatter, "{} already exists", path.display()), + Self::InvalidCopy => formatter.write_str("entry cannot be copied there"), + Self::InvalidMove => formatter.write_str("entry cannot be moved there"), + Self::InvalidName(name) => write!(formatter, "invalid file name {name:?}"), + Self::InvalidPath(path) => write!(formatter, "invalid path {}", path.display()), Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()), + Self::NotDirectory(path) => write!(formatter, "{} is not a directory", path.display()), + Self::OutsideWorkspace(path) => { + write!(formatter, "{} is outside the workspace", path.display()) + } + Self::RootOperation => formatter.write_str("workspace root cannot be changed"), } } } @@ -147,7 +282,15 @@ impl StdError for FileTreeError { fn source(&self) -> Option<&(dyn StdError + 'static)> { match self { Self::WorkspaceUnavailable => None, + Self::AlreadyExists(_) => None, + Self::InvalidCopy => None, + Self::InvalidMove => None, + Self::InvalidName(_) => None, + Self::InvalidPath(_) => None, Self::Io { source, .. } => Some(source), + Self::NotDirectory(_) => None, + Self::OutsideWorkspace(_) => None, + Self::RootOperation => None, } } } @@ -167,6 +310,201 @@ fn read_entry( read_entry_from_parts(path, name, kind, depth, options, remaining_entries) } +fn canonical_workspace_root(root: &Path) -> Result { + fs::canonicalize(root).map_err(|error| FileTreeError::io(root.to_path_buf(), error)) +} + +fn resolve_existing_entry(root: &Path, path: &Path) -> Result { + if is_workspace_root_path(root, path) { + return Ok(root.to_path_buf()); + } + + let parent = path + .parent() + .ok_or_else(|| FileTreeError::InvalidPath(path.to_path_buf()))?; + let parent = + fs::canonicalize(parent).map_err(|error| FileTreeError::io(parent.to_path_buf(), error))?; + + ensure_in_workspace(root, &parent)?; + + let file_name = path + .file_name() + .ok_or_else(|| FileTreeError::InvalidPath(path.to_path_buf()))?; + let resolved = parent.join(file_name); + + fs::symlink_metadata(&resolved).map_err(|error| FileTreeError::io(resolved.clone(), error))?; + Ok(resolved) +} + +fn is_workspace_root_path(root: &Path, path: &Path) -> bool { + if path == root { + return true; + } + + let Some(root_name) = root.file_name() else { + return false; + }; + let Some(path_name) = path.file_name() else { + return false; + }; + if path_name != root_name { + return false; + } + + let Some(root_parent) = root.parent() else { + return false; + }; + let Some(path_parent) = path.parent() else { + return false; + }; + + fs::canonicalize(path_parent).is_ok_and(|parent| parent == root_parent) +} + +fn resolve_existing_directory(root: &Path, path: &Path) -> Result { + let path = + fs::canonicalize(path).map_err(|error| FileTreeError::io(path.to_path_buf(), error))?; + + ensure_in_workspace(root, &path)?; + if !path.is_dir() { + return Err(FileTreeError::NotDirectory(path)); + } + + Ok(path) +} + +fn ensure_in_workspace(root: &Path, path: &Path) -> Result<()> { + if path == root || path.starts_with(root) { + Ok(()) + } else { + Err(FileTreeError::OutsideWorkspace(path.to_path_buf())) + } +} + +fn reject_workspace_root_operation(root: &Path, path: &Path) -> Result<()> { + if path == root { + Err(FileTreeError::RootOperation) + } else { + Ok(()) + } +} + +fn validate_entry_name(name: &str) -> Result<()> { + let invalid = name.is_empty() + || name == "." + || name == ".." + || name.contains(std::path::MAIN_SEPARATOR) + || name.contains('/'); + + if invalid { + Err(FileTreeError::InvalidName(name.to_owned())) + } else { + Ok(()) + } +} + +fn source_is_directory(path: &Path) -> Result { + Ok(fs::symlink_metadata(path) + .map_err(|error| FileTreeError::io(path.to_path_buf(), error))? + .file_type() + .is_dir()) +} + +fn path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn available_copy_target(source: &Path, target_directory: &Path) -> Result { + let file_name = source + .file_name() + .ok_or_else(|| FileTreeError::InvalidPath(source.to_path_buf()))?; + let target = target_directory.join(file_name); + + if target != source && !path_exists(&target) { + return Ok(target); + } + + let file_name = file_name.to_string_lossy(); + let mut index = 1; + + loop { + let target = target_directory.join(numbered_copy_name(&file_name, index)); + if target != source && !path_exists(&target) { + return Ok(target); + } + index += 1; + } +} + +fn numbered_copy_name(file_name: &str, index: usize) -> String { + match file_name.rfind('.') { + Some(dot_index) if dot_index > 0 && dot_index + 1 < file_name.len() => { + let (name, extension) = file_name.split_at(dot_index); + format!("{name} ({index}){extension}") + } + _ => format!("{file_name} ({index})"), + } +} + +fn copy_entry_to(source: &Path, target: &Path) -> Result<()> { + let file_type = fs::symlink_metadata(source) + .map_err(|error| FileTreeError::io(source.to_path_buf(), error))? + .file_type(); + + if file_type.is_dir() { + copy_directory_to(source, target) + } else if file_type.is_symlink() { + copy_symlink_to(source, target) + } else { + fs::copy(source, target) + .map(|_| ()) + .map_err(|error| FileTreeError::io(source.to_path_buf(), error)) + } +} + +fn copy_directory_to(source: &Path, target: &Path) -> Result<()> { + fs::create_dir(target).map_err(|error| FileTreeError::io(target.to_path_buf(), error))?; + + for child in + fs::read_dir(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))? + { + let child = child.map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; + let child_source = child.path(); + let child_target = target.join(child.file_name()); + + if path_exists(&child_target) { + return Err(FileTreeError::AlreadyExists(child_target)); + } + + copy_entry_to(&child_source, &child_target)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn copy_symlink_to(source: &Path, target: &Path) -> Result<()> { + let link_target = + fs::read_link(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; + std::os::unix::fs::symlink(link_target, target) + .map_err(|error| FileTreeError::io(target.to_path_buf(), error)) +} + +#[cfg(windows)] +fn copy_symlink_to(source: &Path, target: &Path) -> Result<()> { + let link_target = + fs::read_link(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; + let metadata = + fs::metadata(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; + + if metadata.is_dir() { + std::os::windows::fs::symlink_dir(link_target, target) + } else { + std::os::windows::fs::symlink_file(link_target, target) + } + .map_err(|error| FileTreeError::io(target.to_path_buf(), error)) +} + fn read_entry_from_parts( path: PathBuf, name: String, @@ -354,6 +692,249 @@ mod tests { remove_test_directory(root); } + #[test] + fn creates_file_and_directory_entries() { + let root = test_directory("create"); + let parent = root.join("src"); + fs::create_dir_all(&parent).expect("directory should be created"); + + let file = create_entry(&root, &parent, "main.rs", FileTreeCreateKind::File) + .expect("file should be created"); + let directory = create_entry(&root, &parent, "nested", FileTreeCreateKind::Directory) + .expect("directory should be created"); + + assert!(file.is_file()); + assert!(directory.is_dir()); + + remove_test_directory(root); + } + + #[test] + fn renames_entries_without_changing_parent() { + let root = test_directory("rename"); + let source = root.join("old.txt"); + fs::write(&source, "old").expect("file should be written"); + + let target = rename_entry(&root, &source, "new.txt").expect("entry should be renamed"); + + assert_eq!(target, root.join("new.txt")); + assert!(!source.exists()); + assert!(target.is_file()); + + remove_test_directory(root); + } + + #[test] + fn deletes_files_and_directories() { + let root = test_directory("delete"); + let file = root.join("delete-me.txt"); + let directory = root.join("delete-me"); + fs::write(&file, "delete").expect("file should be written"); + fs::create_dir_all(directory.join("nested")).expect("directory should be created"); + + delete_entry(&root, &file).expect("file should be deleted"); + delete_entry(&root, &directory).expect("directory should be deleted"); + + assert!(!file.exists()); + assert!(!directory.exists()); + + remove_test_directory(root); + } + + #[test] + fn moves_entries_into_target_directory() { + let root = test_directory("move"); + let source = root.join("main.rs"); + let target_directory = root.join("src"); + fs::write(&source, "fn main() {}").expect("file should be written"); + fs::create_dir_all(&target_directory).expect("directory should be created"); + + let target = move_entry(&root, &source, &target_directory).expect("entry should be moved"); + + assert_eq!(target, target_directory.join("main.rs")); + assert!(!source.exists()); + assert!(target.is_file()); + + remove_test_directory(root); + } + + #[test] + fn copies_files_and_directories_into_target_directory() { + let root = test_directory("copy"); + let file = root.join("main.rs"); + let source_directory = root.join("src"); + let target_directory = root.join("target"); + fs::write(&file, "fn main() {}").expect("file should be written"); + fs::create_dir_all(source_directory.join("nested")).expect("directory should be created"); + fs::write( + source_directory.join("nested").join("lib.rs"), + "pub fn lib() {}", + ) + .expect("file should be written"); + fs::create_dir_all(&target_directory).expect("directory should be created"); + + let copied_file = + copy_entry(&root, &file, &target_directory).expect("file should be copied"); + let copied_directory = copy_entry(&root, &source_directory, &target_directory) + .expect("directory should be copied"); + + assert_eq!(copied_file, target_directory.join("main.rs")); + assert_eq!(copied_directory, target_directory.join("src")); + assert!(file.is_file()); + assert!(copied_file.is_file()); + assert!(copied_directory.join("nested").join("lib.rs").is_file()); + + remove_test_directory(root); + } + + #[test] + fn copies_entries_with_numbered_names_when_target_exists() { + let root = test_directory("copy-numbered"); + let file = root.join("main.rs"); + let directory = root.join("src"); + fs::write(&file, "fn main() {}").expect("file should be written"); + fs::write(root.join("main (1).rs"), "existing").expect("file should be written"); + fs::create_dir_all(directory.join("nested")).expect("directory should be created"); + + let copied_file = copy_entry(&root, &file, &root).expect("file should be copied"); + let copied_directory = + copy_entry(&root, &directory, &root).expect("directory should be copied"); + + assert_eq!(copied_file, root.join("main (2).rs")); + assert_eq!(copied_directory, root.join("src (1)")); + assert!(file.is_file()); + assert!(copied_file.is_file()); + assert!(directory.is_dir()); + assert!(copied_directory.join("nested").is_dir()); + + remove_test_directory(root); + } + + #[cfg(unix)] + #[test] + fn treats_broken_symlink_targets_as_existing() { + let root = test_directory("broken-symlink-targets"); + let source = root.join("source.txt"); + let target_directory = root.join("target"); + let broken = root.join("broken.txt"); + let move_source = root.join("move.txt"); + let move_target = target_directory.join("move.txt"); + let copy_source = root.join("copy.txt"); + let copy_target = target_directory.join("copy.txt"); + + fs::write(&source, "source").expect("file should be written"); + fs::write(&move_source, "move").expect("file should be written"); + fs::write(©_source, "copy").expect("file should be written"); + fs::create_dir_all(&target_directory).expect("directory should be created"); + std::os::unix::fs::symlink(root.join("missing.txt"), &broken) + .expect("symlink should be created"); + std::os::unix::fs::symlink(root.join("missing-move.txt"), &move_target) + .expect("symlink should be created"); + std::os::unix::fs::symlink(root.join("missing-copy.txt"), ©_target) + .expect("symlink should be created"); + + assert!(matches!( + create_entry(&root, &root, "broken.txt", FileTreeCreateKind::File), + Err(FileTreeError::AlreadyExists(_)) + )); + assert!(matches!( + rename_entry(&root, &source, "broken.txt"), + Err(FileTreeError::AlreadyExists(_)) + )); + assert!(matches!( + move_entry(&root, &move_source, &target_directory), + Err(FileTreeError::AlreadyExists(_)) + )); + + let copied = copy_entry(&root, ©_source, &target_directory) + .expect("copy should use a numbered target"); + assert_eq!(copied, target_directory.join("copy (1).txt")); + assert!( + fs::symlink_metadata(copy_target) + .expect("symlink should still exist") + .file_type() + .is_symlink() + ); + + remove_test_directory(root); + } + + #[test] + fn rejects_root_entry_mutations() { + let root = test_directory("root-guard"); + let target_directory = root.join("target"); + fs::create_dir_all(&target_directory).expect("directory should be created"); + + assert!(matches!( + rename_entry(&root, &root, "renamed"), + Err(FileTreeError::RootOperation) + )); + assert!(matches!( + delete_entry(&root, &root), + Err(FileTreeError::RootOperation) + )); + assert!(matches!( + move_entry(&root, &root, &target_directory), + Err(FileTreeError::RootOperation) + )); + assert!(matches!( + copy_entry(&root, &root, &target_directory), + Err(FileTreeError::RootOperation) + )); + + remove_test_directory(root); + } + + #[test] + fn rejects_paths_outside_workspace() { + let root = test_directory("outside-root"); + let outside = test_directory("outside-target"); + let outside_file = outside.join("outside.txt"); + fs::write(&outside_file, "outside").expect("file should be written"); + + assert!(matches!( + create_entry(&root, &outside, "new.txt", FileTreeCreateKind::File), + Err(FileTreeError::OutsideWorkspace(_)) + )); + assert!(matches!( + rename_entry(&root, &outside_file, "renamed.txt"), + Err(FileTreeError::OutsideWorkspace(_)) + )); + + remove_test_directory(root); + remove_test_directory(outside); + } + + #[test] + fn rejects_moving_directory_into_descendant() { + let root = test_directory("move-descendant"); + let source = root.join("src"); + let descendant = source.join("nested"); + fs::create_dir_all(&descendant).expect("directory should be created"); + + assert!(matches!( + move_entry(&root, &source, &descendant), + Err(FileTreeError::InvalidMove) + )); + + remove_test_directory(root); + } + + #[test] + fn rejects_copying_directory_into_descendant() { + let root = test_directory("copy-descendant"); + let source = root.join("src"); + let descendant = source.join("nested"); + fs::create_dir_all(&descendant).expect("directory should be created"); + + assert!(matches!( + copy_entry(&root, &source, &descendant), + Err(FileTreeError::InvalidCopy) + )); + + remove_test_directory(root); + } + fn test_directory(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/core/src/state.rs b/core/src/state.rs index 65e52f99..7af70dff 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::file_tree::{FileTree, FileTreeError, FileTreeOptions}; +use crate::file_tree::{FileTree, FileTreeCreateKind, FileTreeError, FileTreeOptions}; use crate::tree::{ Pane, PaneId, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, @@ -51,14 +51,73 @@ impl State { workspace_id: Option, options: FileTreeOptions, ) -> Result { - let workspace_id = self - .resolve_workspace_id(workspace_id) - .ok_or(FileTreeError::WorkspaceUnavailable)?; - let workspace = self - .workspaces - .workspace(workspace_id) - .ok_or(FileTreeError::WorkspaceUnavailable)?; + let workspace = self.file_tree_workspace(workspace_id)?; + + FileTree::read(workspace.directory(), options) + } + + pub fn create_file_tree_entry( + &self, + workspace_id: Option, + parent: impl AsRef, + name: &str, + kind: FileTreeCreateKind, + options: FileTreeOptions, + ) -> Result { + let workspace = self.file_tree_workspace(workspace_id)?; + + crate::file_tree::create_entry(workspace.directory(), parent, name, kind)?; + FileTree::read(workspace.directory(), options) + } + + pub fn rename_file_tree_entry( + &self, + workspace_id: Option, + path: impl AsRef, + name: &str, + options: FileTreeOptions, + ) -> Result { + let workspace = self.file_tree_workspace(workspace_id)?; + + crate::file_tree::rename_entry(workspace.directory(), path, name)?; + FileTree::read(workspace.directory(), options) + } + + pub fn delete_file_tree_entry( + &self, + workspace_id: Option, + path: impl AsRef, + options: FileTreeOptions, + ) -> Result { + let workspace = self.file_tree_workspace(workspace_id)?; + + crate::file_tree::delete_entry(workspace.directory(), path)?; + FileTree::read(workspace.directory(), options) + } + + pub fn move_file_tree_entry( + &self, + workspace_id: Option, + path: impl AsRef, + target_directory: impl AsRef, + options: FileTreeOptions, + ) -> Result { + let workspace = self.file_tree_workspace(workspace_id)?; + crate::file_tree::move_entry(workspace.directory(), path, target_directory)?; + FileTree::read(workspace.directory(), options) + } + + pub fn copy_file_tree_entry( + &self, + workspace_id: Option, + path: impl AsRef, + target_directory: impl AsRef, + options: FileTreeOptions, + ) -> Result { + let workspace = self.file_tree_workspace(workspace_id)?; + + crate::file_tree::copy_entry(workspace.directory(), path, target_directory)?; FileTree::read(workspace.directory(), options) } @@ -303,6 +362,19 @@ impl State { self.workspaces.workspace_mut(workspace_id) } + fn file_tree_workspace( + &self, + workspace_id: Option, + ) -> Result<&Workspace, FileTreeError> { + let workspace_id = self + .resolve_workspace_id(workspace_id) + .ok_or(FileTreeError::WorkspaceUnavailable)?; + + self.workspaces + .workspace(workspace_id) + .ok_or(FileTreeError::WorkspaceUnavailable) + } + fn resolve_workspace_id(&self, workspace_id: Option) -> Option { workspace_id.or_else(|| self.workspaces.active_workspace_id()) } diff --git a/desktop/src/ipc/kosmos_ipc_client.c b/desktop/src/ipc/kosmos_ipc_client.c index bb23ab96..ee481563 100644 --- a/desktop/src/ipc/kosmos_ipc_client.c +++ b/desktop/src/ipc/kosmos_ipc_client.c @@ -10,6 +10,7 @@ struct _KosmosIpcClient { GDataInputStream *input; GOutputStream *output; guint64 next_request_id; + GMutex request_mutex; }; G_DEFINE_FINAL_TYPE(KosmosIpcClient, kosmos_ipc_client, G_TYPE_OBJECT) @@ -244,6 +245,7 @@ static void kosmos_ipc_client_finalize(GObject *object) { KosmosIpcClient *self = KOSMOS_IPC_CLIENT(object); kosmos_ipc_client_disconnect(self); + g_mutex_clear(&self->request_mutex); g_clear_pointer(&self->socket_path, g_free); G_OBJECT_CLASS(kosmos_ipc_client_parent_class)->finalize(object); @@ -256,6 +258,7 @@ static void kosmos_ipc_client_class_init(KosmosIpcClientClass *klass) { static void kosmos_ipc_client_init(KosmosIpcClient *self) { self->next_request_id = 1; + g_mutex_init(&self->request_mutex); } char *kosmos_ipc_default_socket_path(void) { @@ -371,8 +374,12 @@ gboolean kosmos_ipc_client_request( *result = NULL; } + g_mutex_lock(&self->request_mutex); + if (!kosmos_ipc_client_is_connected(self)) { - return set_not_connected_error(self, error); + gboolean set = set_not_connected_error(self, error); + g_mutex_unlock(&self->request_mutex); + return set; } guint64 request_id = self->next_request_id++; @@ -380,6 +387,7 @@ gboolean kosmos_ipc_client_request( if (!write_frame(self, payload, cancellable, error)) { g_free(payload); + g_mutex_unlock(&self->request_mutex); return FALSE; } @@ -387,11 +395,13 @@ gboolean kosmos_ipc_client_request( char *response = read_frame(self, cancellable, error); if (response == NULL) { + g_mutex_unlock(&self->request_mutex); return FALSE; } gboolean parsed = parse_response(response, request_id, result, error); g_free(response); + g_mutex_unlock(&self->request_mutex); return parsed; } diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h index b3334274..599f538f 100644 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ b/desktop/src/ipc/kosmos_ipc_client.h @@ -72,6 +72,56 @@ gboolean kosmos_ipc_client_list_file_tree( GError **error ); +gboolean kosmos_ipc_client_create_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *parent_path, + const char *name, + gboolean directory, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_rename_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *name, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_delete_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_move_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *target_directory_path, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + +gboolean kosmos_ipc_client_copy_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *target_directory_path, + JsonNode **result, + GCancellable *cancellable, + GError **error +); + gboolean kosmos_ipc_client_activate_pane( KosmosIpcClient *self, guint64 workspace_id, diff --git a/desktop/src/ipc/kosmos_ipc_file_tree.c b/desktop/src/ipc/kosmos_ipc_file_tree.c index ba5aedae..cc94d345 100644 --- a/desktop/src/ipc/kosmos_ipc_file_tree.c +++ b/desktop/src/ipc/kosmos_ipc_file_tree.c @@ -23,3 +23,157 @@ gboolean kosmos_ipc_client_list_file_tree( json_object_unref(params); return requested; } + +gboolean kosmos_ipc_client_create_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *parent_path, + const char *name, + gboolean directory, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(parent_path != NULL, FALSE); + g_return_val_if_fail(name != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_string_member(params, "parentPath", parent_path); + json_object_set_string_member(params, "name", name); + json_object_set_string_member(params, "kind", directory ? "directory" : "file"); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_CREATE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_rename_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *name, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + g_return_val_if_fail(name != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_string_member(params, "path", path); + json_object_set_string_member(params, "name", name); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_RENAME, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_delete_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_string_member(params, "path", path); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_DELETE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_move_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *target_directory_path, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + g_return_val_if_fail(target_directory_path != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_string_member(params, "path", path); + json_object_set_string_member(params, "targetDirectoryPath", target_directory_path); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_MOVE, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} + +gboolean kosmos_ipc_client_copy_file_tree_entry( + KosmosIpcClient *self, + guint64 workspace_id, + const char *path, + const char *target_directory_path, + JsonNode **result, + GCancellable *cancellable, + GError **error +) { + g_return_val_if_fail(path != NULL, FALSE); + g_return_val_if_fail(target_directory_path != NULL, FALSE); + + JsonObject *params = json_object_new(); + json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); + json_object_set_string_member(params, "path", path); + json_object_set_string_member(params, "targetDirectoryPath", target_directory_path); + + gboolean requested = kosmos_ipc_client_request( + self, + KOSMOS_IPC_DOMAIN_FILE_TREE, + KOSMOS_IPC_ACTION_COPY, + params, + result, + cancellable, + error + ); + + json_object_unref(params); + return requested; +} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h index 29bf900a..eacaec23 100644 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ b/desktop/src/ipc/kosmos_ipc_protocol.h @@ -27,6 +27,9 @@ typedef enum { } KosmosIpcTabKind; #define KOSMOS_IPC_ACTION_LIST "list" +#define KOSMOS_IPC_ACTION_CREATE "create" +#define KOSMOS_IPC_ACTION_RENAME "rename" +#define KOSMOS_IPC_ACTION_DELETE "delete" #define KOSMOS_IPC_ACTION_OPEN "open" #define KOSMOS_IPC_ACTION_ACTIVATE "activate" #define KOSMOS_IPC_ACTION_SET_KIND "setKind" @@ -34,6 +37,7 @@ typedef enum { #define KOSMOS_IPC_ACTION_SPLIT "split" #define KOSMOS_IPC_ACTION_REORDER "reorder" #define KOSMOS_IPC_ACTION_MOVE "move" +#define KOSMOS_IPC_ACTION_COPY "copy" #define KOSMOS_IPC_ACTION_RESIZE "resize" const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain); diff --git a/desktop/src/tabs/kosmos_file_tree_tab.c b/desktop/src/tabs/kosmos_file_tree_tab.c index b73614a5..c80b32c2 100644 --- a/desktop/src/tabs/kosmos_file_tree_tab.c +++ b/desktop/src/tabs/kosmos_file_tree_tab.c @@ -1,11 +1,105 @@ #include "tabs/kosmos_file_tree_tab.h" +#include + #define KOSMOS_TYPE_FILE_TREE_ITEM (kosmos_file_tree_item_get_type()) #define KOSMOS_FILE_TREE_ITEM(object) (G_TYPE_CHECK_INSTANCE_CAST((object), KOSMOS_TYPE_FILE_TREE_ITEM, KosmosFileTreeItem)) +#define FILE_TREE_MONITOR_MAX_DIRECTORIES 256 +#define FILE_TREE_MONITOR_MAX_DEPTH 4 typedef struct _KosmosFileTreeItem KosmosFileTreeItem; typedef struct _KosmosFileTreeItemClass KosmosFileTreeItemClass; +typedef struct { + char *path; + char *kind; + char *name; +} FileTreeEntryRef; + +typedef struct { + KosmosIpcClient *ipc_client; + guint64 workspace_id; + GtkWidget *root; + GtkWidget *notice_label; + GtkWidget *scrolled; + GtkWidget *list; + GtkTreeListModel *tree_model; + GHashTable *expanded_paths; + GPtrArray *monitors; + guint monitor_refresh_source_id; + guint busy_count; + char *root_path; + char *root_name; + char *focused_path; + char *pending_focus_path; + char *editing_path; + GPtrArray *clipboard_entries; + gboolean clipboard_cut; +} FileTreeContext; + +typedef enum { + FILE_TREE_ASYNC_LIST, + FILE_TREE_ASYNC_DELETE, + FILE_TREE_ASYNC_MOVE, + FILE_TREE_ASYNC_COPY, +} FileTreeAsyncOperation; + +typedef struct { + GtkWidget *root; + KosmosIpcClient *ipc_client; + guint64 workspace_id; + FileTreeAsyncOperation operation; + char *path; + char *target_directory_path; + GPtrArray *entries; + char *pending_focus_path; + char *error_prefix; + gboolean clear_clipboard_on_success; + gboolean show_progress; +} FileTreeAsyncRequest; + +typedef enum { + FILE_TREE_DIALOG_CREATE_FILE, + FILE_TREE_DIALOG_CREATE_DIRECTORY, +} FileTreeDialogOperation; + +typedef struct { + FileTreeContext *context; + FileTreeDialogOperation operation; + char *path; + AdwDialog *dialog; + GtkWidget *entry; + GtkWidget *error_label; +} FileTreeNameDialog; + +typedef struct { + FileTreeContext *context; + GtkWidget *root; + GPtrArray *entries; +} FileTreeDeleteDialog; + +typedef enum { + FILE_TREE_MENU_NEW_FILE, + FILE_TREE_MENU_NEW_FOLDER, + FILE_TREE_MENU_RENAME, + FILE_TREE_MENU_DELETE, + FILE_TREE_MENU_REFRESH, + FILE_TREE_MENU_CUT, + FILE_TREE_MENU_COPY, + FILE_TREE_MENU_PASTE, + FILE_TREE_MENU_REVEAL, +} FileTreeMenuOperation; + +typedef struct { + FileTreeContext *context; + FileTreeMenuOperation operation; + char *path; + char *kind; + char *name; + GPtrArray *entries; + GtkWidget *popover; +} FileTreeMenuAction; + struct _KosmosFileTreeItem { GObject parent_instance; char *name; @@ -41,6 +135,76 @@ static void kosmos_file_tree_item_init(KosmosFileTreeItem *self) { self->children = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); } +static FileTreeEntryRef *file_tree_entry_ref_new(const char *path, const char *kind, const char *name) { + FileTreeEntryRef *entry = g_new0(FileTreeEntryRef, 1); + + entry->path = g_strdup(path); + entry->kind = g_strdup(kind); + entry->name = g_strdup(name); + + return entry; +} + +static FileTreeEntryRef *file_tree_entry_ref_new_from_item(KosmosFileTreeItem *item) { + return file_tree_entry_ref_new(item->path, item->kind, item->name); +} + +static void file_tree_entry_ref_free(FileTreeEntryRef *entry) { + g_clear_pointer(&entry->path, g_free); + g_clear_pointer(&entry->kind, g_free); + g_clear_pointer(&entry->name, g_free); + g_free(entry); +} + +static void file_tree_context_free(FileTreeContext *context) { + if (context->monitor_refresh_source_id != 0) { + g_source_remove(context->monitor_refresh_source_id); + } + + g_clear_object(&context->ipc_client); + g_clear_object(&context->tree_model); + g_clear_pointer(&context->expanded_paths, g_hash_table_unref); + g_clear_pointer(&context->monitors, g_ptr_array_unref); + g_clear_pointer(&context->root_path, g_free); + g_clear_pointer(&context->root_name, g_free); + g_clear_pointer(&context->focused_path, g_free); + g_clear_pointer(&context->pending_focus_path, g_free); + g_clear_pointer(&context->editing_path, g_free); + g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); + g_free(context); +} + +static void file_tree_async_request_free(FileTreeAsyncRequest *request) { + g_clear_object(&request->root); + g_clear_object(&request->ipc_client); + g_clear_pointer(&request->path, g_free); + g_clear_pointer(&request->target_directory_path, g_free); + g_clear_pointer(&request->entries, g_ptr_array_unref); + g_clear_pointer(&request->pending_focus_path, g_free); + g_clear_pointer(&request->error_prefix, g_free); + g_free(request); +} + +static void file_tree_name_dialog_free(FileTreeNameDialog *request) { + g_clear_pointer(&request->path, g_free); + g_free(request); +} + +static void file_tree_delete_dialog_free(FileTreeDeleteDialog *request) { + g_clear_object(&request->root); + g_clear_pointer(&request->entries, g_ptr_array_unref); + g_free(request); +} + +static void file_tree_menu_action_free(FileTreeMenuAction *action) { + g_clear_pointer(&action->path, g_free); + g_clear_pointer(&action->kind, g_free); + g_clear_pointer(&action->name, g_free); + g_clear_pointer(&action->entries, g_ptr_array_unref); + g_clear_object(&action->popover); + g_free(action); +} + static KosmosFileTreeItem *kosmos_file_tree_item_new(const char *name, const char *path, const char *kind) { KosmosFileTreeItem *item = g_object_new(KOSMOS_TYPE_FILE_TREE_ITEM, NULL); @@ -128,291 +292,1850 @@ static GtkWidget *create_status_label(const char *text, const char *css_class) { return content; } -static const char *entry_icon_name(KosmosFileTreeItem *item) { - if (kosmos_file_tree_item_is_directory(item)) { - return "folder-symbolic"; +static GtkWidget *create_file_tree_list(FileTreeContext *context, JsonObject *root); +static gboolean file_tree_item_is_root(FileTreeContext *context, KosmosFileTreeItem *item); +static gboolean path_is_equal_or_descendant(const char *path, const char *parent); +static void file_tree_context_apply_tree(FileTreeContext *context, JsonNode *tree); +static void apply_file_tree_operation_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix); +static void file_tree_context_refresh(FileTreeContext *context); +static void file_tree_context_refresh_with_progress(FileTreeContext *context, gboolean show_progress); +static GdkContentProvider *file_tree_drag_prepare(GtkDragSource *source, double x, double y, gpointer user_data); +static GdkDragAction file_tree_drop_enter(GtkDropTarget *target, double x, double y, gpointer user_data); +static GdkDragAction file_tree_drop_motion(GtkDropTarget *target, double x, double y, gpointer user_data); +static void file_tree_drop_leave(GtkDropTarget *target, gpointer user_data); +static gboolean file_tree_drop(GtkDropTarget *target, const GValue *value, double x, double y, gpointer user_data); +static void inline_rename_commit(GtkWidget *entry); +static void inline_rename_cancel(GtkWidget *entry); +static gboolean inline_rename_key_pressed(GtkEventControllerKey *controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data); +static void inline_rename_focus_leave(GtkEventControllerFocus *controller, gpointer user_data); + +static void file_tree_install_css(GtkWidget *widget) { + static gboolean installed = FALSE; + + if (installed) { + return; } - if (kosmos_file_tree_item_is_message(item)) { - return "dialog-information-symbolic"; + + GtkCssProvider *provider = gtk_css_provider_new(); + GdkDisplay *display = gtk_widget_get_display(widget); + if (display == NULL) { + display = gdk_display_get_default(); } - if (g_strcmp0(item->kind, "symlink") == 0) { - return "emblem-symbolic-link-symbolic"; + + if (display == NULL) { + g_object_unref(provider); + return; } - return "text-x-generic-symbolic"; + gtk_css_provider_load_from_string( + provider, + ".file-tree-drop-target {" + " background: rgba(46, 160, 67, 0.24);" + "}" + ); + gtk_style_context_add_provider_for_display( + display, + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + ); + g_object_unref(provider); + installed = TRUE; } -static void append_message_child(KosmosFileTreeItem *item, const char *message) { - KosmosFileTreeItem *message_item = kosmos_file_tree_item_new_message(message); - g_list_store_append(item->children, message_item); - g_object_unref(message_item); +static void file_tree_context_set_focused_path(FileTreeContext *context, const char *path) { + if (g_strcmp0(context->focused_path, path) == 0) { + return; + } + + g_free(context->focused_path); + context->focused_path = g_strdup(path); } -static KosmosFileTreeItem *file_tree_item_from_entry(JsonObject *entry) { - const char *name = NULL; - const char *path = NULL; - const char *kind = NULL; - const char *read_error = NULL; - gboolean children_truncated = FALSE; +static void file_tree_context_set_pending_focus_path(FileTreeContext *context, const char *path) { + g_free(context->pending_focus_path); + context->pending_focus_path = g_strdup(path); +} - if (!get_string_member(entry, "name", &name) || !get_string_member(entry, "kind", &kind)) { - return kosmos_file_tree_item_new_message("Invalid file tree entry."); - } +static void file_tree_context_set_editing_path(FileTreeContext *context, const char *path) { + g_free(context->editing_path); + context->editing_path = g_strdup(path); +} - get_string_member(entry, "path", &path); - get_string_member(entry, "readError", &read_error); - get_bool_member(entry, "childrenTruncated", &children_truncated); +static void file_tree_context_clear_editing_path(FileTreeContext *context) { + g_clear_pointer(&context->editing_path, g_free); +} - KosmosFileTreeItem *item = kosmos_file_tree_item_new(name, path, kind); +static char *file_tree_path_child(const char *parent, const char *name) { + return g_build_filename(parent, name, NULL); +} - JsonArray *children = get_array_member(entry, "children"); - guint child_count = children == NULL ? 0 : json_array_get_length(children); - for (guint index = 0; index < child_count; index++) { - JsonNode *child_node = json_array_get_element(children, index); - if (child_node == NULL || !JSON_NODE_HOLDS_OBJECT(child_node)) { - append_message_child(item, "Invalid file tree entry."); - continue; - } +static char *file_tree_numbered_copy_name(const char *name, guint index) { + const char *dot = strrchr(name, '.'); - KosmosFileTreeItem *child = file_tree_item_from_entry(json_node_get_object(child_node)); - g_list_store_append(item->children, child); - g_object_unref(child); + if (dot != NULL && dot > name && dot[1] != '\0') { + return g_strdup_printf("%.*s (%u)%s", (int)(dot - name), name, index, dot); } - if (children_truncated) { - append_message_child(item, "Directory listing is truncated."); - } - if (read_error != NULL) { - append_message_child(item, read_error); + return g_strdup_printf("%s (%u)", name, index); +} + +static char *file_tree_available_copy_path(const char *target_directory, const char *name) { + char *target = file_tree_path_child(target_directory, name); + + if (!g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { + return target; } - return item; -} + g_free(target); -static GListModel *create_child_model(gpointer item, gpointer user_data) { - (void)user_data; + for (guint index = 1;; index++) { + char *numbered_name = file_tree_numbered_copy_name(name, index); + target = file_tree_path_child(target_directory, numbered_name); + g_free(numbered_name); - KosmosFileTreeItem *entry = KOSMOS_FILE_TREE_ITEM(item); - if (kosmos_file_tree_item_child_count(entry) == 0) { - return NULL; + if (!g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { + return target; + } + + g_free(target); } +} - return G_LIST_MODEL(g_object_ref(entry->children)); +static gboolean file_tree_name_is_valid(const char *name) { + return name != NULL && name[0] != '\0' && g_strcmp0(name, ".") != 0 && g_strcmp0(name, "..") != 0 && strchr(name, G_DIR_SEPARATOR) == NULL && strchr(name, '/') == NULL; } -static void setup_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; - (void)user_data; +static char *file_tree_path_parent(const char *path) { + return g_path_get_dirname(path); +} - GtkWidget *hit_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); - GtkWidget *icon = gtk_image_new(); - GtkWidget *label = gtk_label_new(NULL); +static char *file_tree_paste_target_directory(const char *path, const char *kind) { + return g_strcmp0(kind, "directory") == 0 ? g_strdup(path) : file_tree_path_parent(path); +} - gtk_list_item_set_selectable(list_item, FALSE); - gtk_widget_set_margin_top(row, 3); - gtk_widget_set_margin_bottom(row, 3); - gtk_widget_set_margin_end(row, 10); - gtk_widget_set_hexpand(hit_row, TRUE); - gtk_widget_set_halign(hit_row, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(row, TRUE); - gtk_widget_set_halign(row, GTK_ALIGN_FILL); - gtk_label_set_xalign(GTK_LABEL(label), 0.0f); - gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); - gtk_widget_set_hexpand(label, TRUE); +static GPtrArray *file_tree_entry_ref_array_new(void) { + return g_ptr_array_new_with_free_func((GDestroyNotify)file_tree_entry_ref_free); +} - gtk_box_append(GTK_BOX(row), icon); - gtk_box_append(GTK_BOX(row), label); - gtk_box_append(GTK_BOX(hit_row), row); - g_object_set_data(G_OBJECT(hit_row), "entry-row", row); - g_object_set_data(G_OBJECT(hit_row), "entry-icon", icon); - g_object_set_data(G_OBJECT(hit_row), "entry-label", label); +static GPtrArray *file_tree_entry_ref_array_copy(GPtrArray *entries) { + GPtrArray *copy = file_tree_entry_ref_array_new(); - gtk_list_item_set_child(list_item, hit_row); -} + for (guint index = 0; index < entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(entries, index); + g_ptr_array_add(copy, file_tree_entry_ref_new(entry->path, entry->kind, entry->name)); + } -static void set_tree_row_data(GtkWidget *widget, GtkTreeListRow *tree_row) { - g_object_set_data_full(G_OBJECT(widget), "tree-row", g_object_ref(tree_row), g_object_unref); + return copy; } -static GtkTreeListRow *tree_row_data(GtkWidget *widget) { - GtkTreeListRow *tree_row = g_object_get_data(G_OBJECT(widget), "tree-row"); +static void file_tree_context_store_clipboard(FileTreeContext *context, GPtrArray *entries, gboolean cut) { + g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); + context->clipboard_entries = file_tree_entry_ref_array_new(); - return tree_row == NULL ? NULL : g_object_ref(tree_row); + for (guint index = 0; index < entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(entries, index); + g_ptr_array_add(context->clipboard_entries, file_tree_entry_ref_new(entry->path, entry->kind, entry->name)); + } + + context->clipboard_cut = cut; } -static void bind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; - (void)user_data; +static void file_tree_context_clear_clipboard(FileTreeContext *context) { + g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); + context->clipboard_cut = FALSE; +} - GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item); - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); - GtkWidget *hit_row = gtk_list_item_get_child(list_item); - GtkWidget *row = g_object_get_data(G_OBJECT(hit_row), "entry-row"); - GtkWidget *icon = g_object_get_data(G_OBJECT(hit_row), "entry-icon"); - GtkWidget *label = g_object_get_data(G_OBJECT(hit_row), "entry-label"); - guint depth = gtk_tree_list_row_get_depth(tree_row); +static gboolean file_tree_context_has_clipboard(FileTreeContext *context) { + if (context->clipboard_entries == NULL || context->clipboard_entries->len == 0) { + return FALSE; + } - gtk_widget_set_margin_start(row, 10 + (int)(depth * 18)); - gtk_image_set_from_icon_name(GTK_IMAGE(icon), entry_icon_name(item)); - gtk_label_set_text(GTK_LABEL(label), item->name); - gtk_widget_set_tooltip_text(hit_row, item->path[0] == '\0' ? NULL : item->path); - set_tree_row_data(hit_row, tree_row); + for (guint index = 0; index < context->clipboard_entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(context->clipboard_entries, index); + if (!g_file_test(entry->path, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { + file_tree_context_clear_clipboard(context); + return FALSE; + } + } - if (kosmos_file_tree_item_is_message(item)) { - gtk_widget_add_css_class(label, "dim-label"); - gtk_widget_add_css_class(icon, "dim-label"); - } else { - gtk_widget_remove_css_class(label, "dim-label"); - gtk_widget_remove_css_class(icon, "dim-label"); + return TRUE; +} + +static gboolean file_tree_context_clipboard_intersects_entries(FileTreeContext *context, GPtrArray *entries) { + if (context->clipboard_entries == NULL) { + return FALSE; + } + + for (guint clipboard_index = 0; clipboard_index < context->clipboard_entries->len; clipboard_index++) { + FileTreeEntryRef *clipboard_entry = g_ptr_array_index(context->clipboard_entries, clipboard_index); + for (guint entry_index = 0; entry_index < entries->len; entry_index++) { + FileTreeEntryRef *entry = g_ptr_array_index(entries, entry_index); + if (path_is_equal_or_descendant(clipboard_entry->path, entry->path)) { + return TRUE; + } + } } + + return FALSE; } -static GtkTreeListRow *tree_row_in_descendants(GtkWidget *widget) { - if (widget == NULL) { +static const char *file_tree_context_clipboard_conflict_name(FileTreeContext *context, const char *target_directory) { + if (context->clipboard_entries == NULL) { return NULL; } - GtkTreeListRow *tree_row = tree_row_data(widget); - if (tree_row != NULL) { - return tree_row; - } + for (guint index = 0; index < context->clipboard_entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(context->clipboard_entries, index); + char *target = file_tree_path_child(target_directory, entry->name); + gboolean conflict = g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK); + g_free(target); - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - GtkTreeListRow *child_row = tree_row_in_descendants(child); - if (child_row != NULL) { - return child_row; + if (conflict) { + return entry->name; } } return NULL; } -static GtkTreeListRow *tree_row_in_ancestors(GtkWidget *widget) { - for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { - GtkTreeListRow *tree_row = tree_row_data(ancestor); - if (tree_row != NULL) { - return tree_row; - } +static void file_tree_context_set_notice(FileTreeContext *context, const char *message, const char *css_class) { + if (context->notice_label == NULL) { + return; } - return NULL; + gtk_label_set_text(GTK_LABEL(context->notice_label), message == NULL ? "" : message); + gtk_widget_set_visible(context->notice_label, message != NULL && message[0] != '\0'); + gtk_widget_remove_css_class(context->notice_label, "error"); + gtk_widget_remove_css_class(context->notice_label, "dim-label"); + + if (css_class != NULL) { + gtk_widget_add_css_class(context->notice_label, css_class); + } } -static GtkTreeListRow *tree_row_for_picked_widget(GtkWidget *widget) { - gboolean picked_list_item = FALSE; +static void file_tree_context_begin_operation(FileTreeContext *context, const char *message) { + context->busy_count++; + file_tree_context_set_notice(context, message == NULL ? "Working..." : message, "dim-label"); +} - for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { - if (g_strcmp0(G_OBJECT_TYPE_NAME(ancestor), "GtkListItemWidget") == 0) { - picked_list_item = TRUE; - break; - } +static void file_tree_context_finish_operation(FileTreeContext *context) { + if (context->busy_count > 0) { + context->busy_count--; } - if (!picked_list_item) { - return NULL; + if (context->busy_count == 0) { + file_tree_context_set_notice(context, NULL, NULL); } +} - GtkTreeListRow *tree_row = tree_row_in_ancestors(widget); +static FileTreeAsyncRequest *file_tree_async_request_new( + FileTreeContext *context, + FileTreeAsyncOperation operation, + const char *error_prefix +) { + FileTreeAsyncRequest *request = g_new0(FileTreeAsyncRequest, 1); - return tree_row == NULL ? tree_row_in_descendants(widget) : tree_row; + request->root = g_object_ref(context->root); + request->ipc_client = g_object_ref(context->ipc_client); + request->workspace_id = context->workspace_id; + request->operation = operation; + request->error_prefix = g_strdup(error_prefix); + + return request; } -static void file_tree_clicked(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)n_press; +static void file_tree_async_request_thread(GTask *task, gpointer source_object, gpointer task_data, GCancellable *cancellable) { + (void)source_object; - GtkWidget *root = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); - GtkWidget *list = GTK_WIDGET(user_data); - graphene_point_t root_point = GRAPHENE_POINT_INIT((float)x, (float)y); - graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - GtkTreeListRow *tree_row = NULL; + FileTreeAsyncRequest *request = task_data; + GError *error = NULL; + JsonNode *tree = NULL; + gboolean ok = FALSE; + + if (request->entries != NULL) { + for (guint index = 0; index < request->entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(request->entries, index); + + g_clear_pointer(&tree, json_node_unref); + switch (request->operation) { + case FILE_TREE_ASYNC_DELETE: + ok = kosmos_ipc_client_delete_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_MOVE: + ok = kosmos_ipc_client_move_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, request->target_directory_path, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_COPY: + ok = kosmos_ipc_client_copy_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, request->target_directory_path, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_LIST: + ok = kosmos_ipc_client_list_file_tree(request->ipc_client, request->workspace_id, &tree, cancellable, &error); + break; + } + + if (!ok || error != NULL) { + if (tree != NULL) { + json_node_unref(tree); + } + g_task_return_error(task, error == NULL ? g_error_new(KOSMOS_IPC_ERROR, KOSMOS_IPC_ERROR_IO, "IPC request failed") : error); + return; + } + } - if (gtk_widget_compute_point(root, list, &root_point, &list_point)) { - GtkWidget *picked = gtk_widget_pick(list, list_point.x, list_point.y, GTK_PICK_DEFAULT); - tree_row = tree_row_for_picked_widget(picked); + g_task_return_pointer(task, tree, (GDestroyNotify)json_node_unref); + return; } - if (tree_row != NULL && gtk_tree_list_row_is_expandable(tree_row)) { - gtk_tree_list_row_set_expanded(tree_row, !gtk_tree_list_row_get_expanded(tree_row)); + switch (request->operation) { + case FILE_TREE_ASYNC_LIST: + ok = kosmos_ipc_client_list_file_tree(request->ipc_client, request->workspace_id, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_DELETE: + ok = kosmos_ipc_client_delete_file_tree_entry(request->ipc_client, request->workspace_id, request->path, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_MOVE: + ok = kosmos_ipc_client_move_file_tree_entry(request->ipc_client, request->workspace_id, request->path, request->target_directory_path, &tree, cancellable, &error); + break; + case FILE_TREE_ASYNC_COPY: + ok = kosmos_ipc_client_copy_file_tree_entry(request->ipc_client, request->workspace_id, request->path, request->target_directory_path, &tree, cancellable, &error); + break; } - g_clear_object(&tree_row); + if (!ok || error != NULL) { + if (tree != NULL) { + json_node_unref(tree); + } + g_task_return_error(task, error == NULL ? g_error_new(KOSMOS_IPC_ERROR, KOSMOS_IPC_ERROR_IO, "IPC request failed") : error); + return; + } + + g_task_return_pointer(task, tree, (GDestroyNotify)json_node_unref); } -static void unbind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; +static void file_tree_async_request_done(GObject *source_object, GAsyncResult *result, gpointer user_data) { + (void)source_object; (void)user_data; - GtkWidget *hit_row = gtk_list_item_get_child(list_item); - if (hit_row != NULL) { - g_object_set_data(G_OBJECT(hit_row), "tree-row", NULL); + GTask *task = G_TASK(result); + FileTreeAsyncRequest *request = g_task_get_task_data(task); + FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); + GError *error = NULL; + JsonNode *tree = g_task_propagate_pointer(task, &error); + + if (context == NULL) { + g_clear_error(&error); + if (tree != NULL) { + json_node_unref(tree); + } + return; } -} -static GtkWidget *create_file_tree_list(JsonObject *root) { - GListStore *root_store = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); - KosmosFileTreeItem *root_item = file_tree_item_from_entry(root); - g_list_store_append(root_store, root_item); - g_object_unref(root_item); + if (request->show_progress) { + file_tree_context_finish_operation(context); + } - GtkTreeListModel *tree_model = gtk_tree_list_model_new( - G_LIST_MODEL(root_store), - FALSE, - FALSE, - create_child_model, - NULL, - NULL - ); - GtkTreeListRow *root_row = g_list_model_get_item(G_LIST_MODEL(tree_model), 0); - if (root_row != NULL) { - gtk_tree_list_row_set_expanded(root_row, TRUE); - g_object_unref(root_row); + if (error != NULL) { + char *message = g_strdup_printf("%s: %s", request->error_prefix, error->message); + file_tree_context_set_notice(context, message, "error"); + g_free(message); + g_clear_error(&error); + file_tree_context_refresh_with_progress(context, FALSE); + return; } - GtkListItemFactory *factory = gtk_signal_list_item_factory_new(); - g_signal_connect(factory, "setup", G_CALLBACK(setup_list_item), NULL); - g_signal_connect(factory, "bind", G_CALLBACK(bind_list_item), NULL); - g_signal_connect(factory, "unbind", G_CALLBACK(unbind_list_item), NULL); + if (request->pending_focus_path != NULL) { + file_tree_context_set_pending_focus_path(context, request->pending_focus_path); + } + if (request->clear_clipboard_on_success) { + file_tree_context_clear_clipboard(context); + } - GtkSelectionModel *selection = GTK_SELECTION_MODEL(gtk_no_selection_new(G_LIST_MODEL(tree_model))); - GtkWidget *list = gtk_list_view_new(selection, factory); + file_tree_context_apply_tree(context, tree); + if (tree != NULL) { + json_node_unref(tree); + } +} - gtk_widget_set_margin_bottom(list, 8); - gtk_widget_set_vexpand(list, TRUE); - gtk_widget_set_hexpand(list, TRUE); +static void file_tree_async_request_start(FileTreeContext *context, FileTreeAsyncRequest *request, const char *progress_message) { + if (request->show_progress) { + file_tree_context_begin_operation(context, progress_message); + } - return list; + GTask *task = g_task_new(request->ipc_client, NULL, file_tree_async_request_done, NULL); + g_task_set_task_data(task, request, (GDestroyNotify)file_tree_async_request_free); + g_task_run_in_thread(task, file_tree_async_request_thread); + g_object_unref(task); } -static GtkWidget *create_tree_view(JsonNode *tree) { - if (tree == NULL || !JSON_NODE_HOLDS_OBJECT(tree)) { - return create_status_label("File tree is unavailable.", "dim-label"); +static void file_tree_copy_conflict_response(AdwAlertDialog *dialog, GAsyncResult *result, gpointer user_data) { + FileTreeAsyncRequest *request = user_data; + const char *response = adw_alert_dialog_choose_finish(dialog, result); + FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); + + if (context != NULL && g_strcmp0(response, "keep-both") == 0) { + gboolean batch = request->entries != NULL && request->entries->len > 1; + file_tree_async_request_start(context, request, batch ? "Copying entries..." : "Copying entry..."); + return; + } + + file_tree_async_request_free(request); +} + +static void file_tree_confirm_copy_conflict(FileTreeContext *context, FileTreeAsyncRequest *request, const char *name) { + AdwDialog *dialog = adw_alert_dialog_new("Name Already Exists", NULL); + adw_alert_dialog_format_body( + ADW_ALERT_DIALOG(dialog), + "An entry named \"%s\" already exists in this folder. Keep both and create a numbered copy?", + name + ); + adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(dialog), "cancel", "_Cancel", "keep-both", "Keep _Both", NULL); + adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(dialog), "cancel"); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "keep-both"); + adw_alert_dialog_choose(ADW_ALERT_DIALOG(dialog), context->root, NULL, (GAsyncReadyCallback)file_tree_copy_conflict_response, request); +} + +static void file_tree_monitor_free(gpointer data) { + GFileMonitor *monitor = data; + + g_file_monitor_cancel(monitor); + g_object_unref(monitor); +} + +static gboolean file_tree_monitor_refresh(gpointer user_data); + +static void file_tree_context_schedule_monitor_refresh(FileTreeContext *context) { + if (context->monitor_refresh_source_id != 0) { + return; + } + + context->monitor_refresh_source_id = g_timeout_add(150, file_tree_monitor_refresh, context); +} + +static gboolean file_tree_monitor_refresh(gpointer user_data) { + FileTreeContext *context = user_data; + + context->monitor_refresh_source_id = 0; + file_tree_context_refresh_with_progress(context, FALSE); + + return G_SOURCE_REMOVE; +} + +static void file_tree_monitor_changed( + GFileMonitor *monitor, + GFile *file, + GFile *other_file, + GFileMonitorEvent event, + gpointer user_data +) { + (void)monitor; + (void)file; + (void)other_file; + (void)event; + + file_tree_context_schedule_monitor_refresh(user_data); +} + +static void file_tree_context_watch_directory(FileTreeContext *context, const char *path) { + if (context->monitors->len >= FILE_TREE_MONITOR_MAX_DIRECTORIES) { + return; + } + + GFile *file = g_file_new_for_path(path); + GError *error = NULL; + GFileMonitor *monitor = g_file_monitor_directory(file, G_FILE_MONITOR_NONE, NULL, &error); + + if (monitor != NULL && context->monitors->len < FILE_TREE_MONITOR_MAX_DIRECTORIES) { + g_signal_connect(monitor, "changed", G_CALLBACK(file_tree_monitor_changed), context); + g_ptr_array_add(context->monitors, monitor); + monitor = NULL; + } + + g_clear_object(&monitor); + g_clear_error(&error); + g_object_unref(file); +} + +static void file_tree_context_watch_item(FileTreeContext *context, KosmosFileTreeItem *item, GHashTable *paths, guint depth) { + if (depth > FILE_TREE_MONITOR_MAX_DEPTH || context->monitors->len >= FILE_TREE_MONITOR_MAX_DIRECTORIES) { + return; + } + + if (kosmos_file_tree_item_is_directory(item) && item->path[0] != '\0' && g_hash_table_add(paths, g_strdup(item->path))) { + file_tree_context_watch_directory(context, item->path); + } + + if (depth == FILE_TREE_MONITOR_MAX_DEPTH) { + return; + } + + guint child_count = kosmos_file_tree_item_child_count(item); + for (guint index = 0; index < child_count; index++) { + KosmosFileTreeItem *child = g_list_model_get_item(G_LIST_MODEL(item->children), index); + + if (child != NULL && !kosmos_file_tree_item_is_message(child)) { + file_tree_context_watch_item(context, child, paths, depth + 1); + } + + g_clear_object(&child); + } +} + +static void file_tree_context_rebuild_monitors(FileTreeContext *context, KosmosFileTreeItem *root) { + g_clear_pointer(&context->monitors, g_ptr_array_unref); + context->monitors = g_ptr_array_new_with_free_func(file_tree_monitor_free); + + GHashTable *paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + file_tree_context_watch_item(context, root, paths, 0); + g_hash_table_unref(paths); +} + +static void file_tree_context_remember_expanded(FileTreeContext *context) { + if (context->tree_model == NULL) { + return; + } + + GHashTable *expanded_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + guint row_count = g_list_model_get_n_items(G_LIST_MODEL(context->tree_model)); + + for (guint index = 0; index < row_count; index++) { + GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(context->tree_model), index); + if (row == NULL) { + continue; + } + + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); + if (kosmos_file_tree_item_is_directory(item) && !file_tree_item_is_root(context, item) && + gtk_tree_list_row_get_expanded(row)) { + g_hash_table_add(expanded_paths, g_strdup(item->path)); + } + + g_object_unref(row); + } + + g_clear_pointer(&context->expanded_paths, g_hash_table_unref); + context->expanded_paths = expanded_paths; +} + +static gboolean file_tree_context_path_was_expanded(FileTreeContext *context, const char *path) { + return context->expanded_paths != NULL && g_hash_table_contains(context->expanded_paths, path); +} + +static void file_tree_context_restore_expanded(FileTreeContext *context, GtkTreeListModel *tree_model) { + for (guint index = 0; index < g_list_model_get_n_items(G_LIST_MODEL(tree_model)); index++) { + GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(tree_model), index); + if (row == NULL) { + continue; + } + + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); + if (kosmos_file_tree_item_is_directory(item) && + (file_tree_item_is_root(context, item) || file_tree_context_path_was_expanded(context, item->path))) { + gtk_tree_list_row_set_expanded(row, TRUE); + } + + g_object_unref(row); + } +} + +static void file_tree_context_set_status(FileTreeContext *context, const char *text, const char *css_class) { + context->list = NULL; + g_clear_object(&context->tree_model); + g_clear_pointer(&context->monitors, g_ptr_array_unref); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(context->scrolled), create_status_label(text, css_class)); +} + +static void file_tree_context_apply_tree(FileTreeContext *context, JsonNode *tree) { + if (tree == NULL || !JSON_NODE_HOLDS_OBJECT(tree)) { + file_tree_context_set_status(context, "File tree is unavailable.", "dim-label"); + return; } JsonObject *result = json_node_get_object(tree); JsonObject *root = get_object_member(result, "root"); - if (root == NULL) { - return create_status_label("File tree response is invalid.", "error"); + const char *root_path = NULL; + const char *root_name = NULL; + if (root == NULL || !get_string_member(root, "path", &root_path) || !get_string_member(root, "name", &root_name)) { + file_tree_context_set_status(context, "File tree response is invalid.", "error"); + return; + } + + file_tree_context_remember_expanded(context); + g_clear_object(&context->tree_model); + if (context->pending_focus_path != NULL) { + file_tree_context_set_focused_path(context, context->pending_focus_path); + g_clear_pointer(&context->pending_focus_path, g_free); + } else if (context->focused_path == NULL) { + file_tree_context_set_focused_path(context, root_path); + } + + g_free(context->root_path); + g_free(context->root_name); + context->root_path = g_strdup(root_path); + context->root_name = g_strdup(root_name); + context->list = create_file_tree_list(context, root); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(context->scrolled), context->list); + gtk_widget_grab_focus(context->list); +} + +static void file_tree_context_apply_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix) { + if (error != NULL) { + char *message = g_strdup_printf("%s: %s", prefix, error->message); + file_tree_context_set_status(context, message, "error"); + g_free(message); + return; + } + + file_tree_context_apply_tree(context, tree); +} + +static void file_tree_context_refresh_with_progress(FileTreeContext *context, gboolean show_progress) { + if (context->monitor_refresh_source_id != 0) { + g_source_remove(context->monitor_refresh_source_id); + context->monitor_refresh_source_id = 0; + } + + FileTreeAsyncRequest *request = file_tree_async_request_new(context, FILE_TREE_ASYNC_LIST, "Failed to load file tree"); + request->show_progress = show_progress; + file_tree_async_request_start(context, request, "Refreshing file tree..."); +} + +static void file_tree_context_refresh(FileTreeContext *context) { + file_tree_context_refresh_with_progress(context, TRUE); +} + +static const char *entry_icon_name(KosmosFileTreeItem *item) { + if (kosmos_file_tree_item_is_directory(item)) { + return "folder-symbolic"; + } + if (kosmos_file_tree_item_is_message(item)) { + return "dialog-information-symbolic"; + } + if (g_strcmp0(item->kind, "symlink") == 0) { + return "emblem-symbolic-link-symbolic"; + } + + return "text-x-generic-symbolic"; +} + +static void append_message_child(KosmosFileTreeItem *item, const char *message) { + KosmosFileTreeItem *message_item = kosmos_file_tree_item_new_message(message); + g_list_store_append(item->children, message_item); + g_object_unref(message_item); +} + +static KosmosFileTreeItem *file_tree_item_from_entry(JsonObject *entry) { + const char *name = NULL; + const char *path = NULL; + const char *kind = NULL; + const char *read_error = NULL; + gboolean children_truncated = FALSE; + + if (!get_string_member(entry, "name", &name) || !get_string_member(entry, "kind", &kind)) { + return kosmos_file_tree_item_new_message("Invalid file tree entry."); + } + + get_string_member(entry, "path", &path); + get_string_member(entry, "readError", &read_error); + get_bool_member(entry, "childrenTruncated", &children_truncated); + + KosmosFileTreeItem *item = kosmos_file_tree_item_new(name, path, kind); + + JsonArray *children = get_array_member(entry, "children"); + guint child_count = children == NULL ? 0 : json_array_get_length(children); + for (guint index = 0; index < child_count; index++) { + JsonNode *child_node = json_array_get_element(children, index); + if (child_node == NULL || !JSON_NODE_HOLDS_OBJECT(child_node)) { + append_message_child(item, "Invalid file tree entry."); + continue; + } + + KosmosFileTreeItem *child = file_tree_item_from_entry(json_node_get_object(child_node)); + g_list_store_append(item->children, child); + g_object_unref(child); + } + + if (children_truncated) { + append_message_child(item, "Directory listing is truncated."); + } + if (read_error != NULL) { + append_message_child(item, read_error); + } + + return item; +} + +static GListModel *create_child_model(gpointer item, gpointer user_data) { + (void)user_data; + + KosmosFileTreeItem *entry = KOSMOS_FILE_TREE_ITEM(item); + if (kosmos_file_tree_item_child_count(entry) == 0) { + return NULL; + } + + return G_LIST_MODEL(g_object_ref(entry->children)); +} + +static void setup_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + (void)user_data; + + GtkWidget *hit_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *icon = gtk_image_new(); + GtkWidget *label = gtk_label_new(NULL); + GtkWidget *entry = gtk_entry_new(); + GtkDragSource *drag_source = gtk_drag_source_new(); + GtkDropTarget *drop_target = gtk_drop_target_new(KOSMOS_TYPE_FILE_TREE_ITEM, GDK_ACTION_MOVE); + GtkEventController *entry_key = gtk_event_controller_key_new(); + GtkEventController *entry_focus = gtk_event_controller_focus_new(); + + gtk_list_item_set_selectable(list_item, TRUE); + gtk_widget_set_margin_top(row, 3); + gtk_widget_set_margin_bottom(row, 3); + gtk_widget_set_margin_end(row, 10); + gtk_widget_set_hexpand(hit_row, TRUE); + gtk_widget_set_halign(hit_row, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(row, TRUE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_hexpand(entry, TRUE); + gtk_widget_set_visible(entry, FALSE); + + gtk_box_append(GTK_BOX(row), icon); + gtk_box_append(GTK_BOX(row), label); + gtk_box_append(GTK_BOX(row), entry); + gtk_box_append(GTK_BOX(hit_row), row); + g_object_set_data(G_OBJECT(hit_row), "entry-row", row); + g_object_set_data(G_OBJECT(hit_row), "entry-icon", icon); + g_object_set_data(G_OBJECT(hit_row), "entry-label", label); + g_object_set_data(G_OBJECT(hit_row), "entry-editor", entry); + gtk_drag_source_set_actions(drag_source, GDK_ACTION_MOVE); + gtk_drop_target_set_preload(drop_target, TRUE); + g_signal_connect(drag_source, "prepare", G_CALLBACK(file_tree_drag_prepare), NULL); + g_signal_connect(drop_target, "enter", G_CALLBACK(file_tree_drop_enter), NULL); + g_signal_connect(drop_target, "motion", G_CALLBACK(file_tree_drop_motion), NULL); + g_signal_connect(drop_target, "leave", G_CALLBACK(file_tree_drop_leave), NULL); + g_signal_connect(drop_target, "drop", G_CALLBACK(file_tree_drop), NULL); + gtk_widget_add_controller(hit_row, GTK_EVENT_CONTROLLER(drag_source)); + gtk_widget_add_controller(hit_row, GTK_EVENT_CONTROLLER(drop_target)); + g_signal_connect(entry, "activate", G_CALLBACK(inline_rename_commit), NULL); + g_signal_connect(entry_key, "key-pressed", G_CALLBACK(inline_rename_key_pressed), NULL); + g_signal_connect(entry_focus, "leave", G_CALLBACK(inline_rename_focus_leave), NULL); + gtk_widget_add_controller(entry, entry_key); + gtk_widget_add_controller(entry, entry_focus); + + gtk_list_item_set_child(list_item, hit_row); +} + +static void set_tree_row_data(GtkWidget *widget, GtkTreeListRow *tree_row) { + g_object_set_data_full(G_OBJECT(widget), "tree-row", g_object_ref(tree_row), g_object_unref); +} + +static GtkTreeListRow *tree_row_data(GtkWidget *widget) { + GtkTreeListRow *tree_row = g_object_get_data(G_OBJECT(widget), "tree-row"); + + return tree_row == NULL ? NULL : g_object_ref(tree_row); +} + +static void bind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + + FileTreeContext *context = user_data; + GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item); + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); + GtkWidget *hit_row = gtk_list_item_get_child(list_item); + GtkWidget *row = g_object_get_data(G_OBJECT(hit_row), "entry-row"); + GtkWidget *icon = g_object_get_data(G_OBJECT(hit_row), "entry-icon"); + GtkWidget *label = g_object_get_data(G_OBJECT(hit_row), "entry-label"); + GtkWidget *entry = g_object_get_data(G_OBJECT(hit_row), "entry-editor"); + guint depth = gtk_tree_list_row_get_depth(tree_row); + gboolean editing = g_strcmp0(context->editing_path, item->path) == 0; + + gtk_widget_set_margin_start(row, 10 + (int)(depth * 18)); + gtk_image_set_from_icon_name(GTK_IMAGE(icon), entry_icon_name(item)); + gtk_label_set_text(GTK_LABEL(label), item->name); + gtk_editable_set_text(GTK_EDITABLE(entry), item->name); + gtk_widget_set_visible(label, !editing); + gtk_widget_set_visible(entry, editing); + gtk_widget_set_tooltip_text(hit_row, item->path[0] == '\0' ? NULL : item->path); + set_tree_row_data(hit_row, tree_row); + g_object_set_data(G_OBJECT(hit_row), "file-tree-context", context); + g_object_set_data_full(G_OBJECT(hit_row), "file-tree-item", g_object_ref(item), g_object_unref); + g_object_set_data(G_OBJECT(entry), "file-tree-context", context); + g_object_set_data_full(G_OBJECT(entry), "file-tree-item", g_object_ref(item), g_object_unref); + + if (kosmos_file_tree_item_is_message(item)) { + gtk_widget_add_css_class(label, "dim-label"); + gtk_widget_add_css_class(icon, "dim-label"); + } else { + gtk_widget_remove_css_class(label, "dim-label"); + gtk_widget_remove_css_class(icon, "dim-label"); + } + + if (editing) { + gtk_widget_grab_focus(entry); + gtk_editable_select_region(GTK_EDITABLE(entry), 0, -1); + } +} + +static GtkTreeListRow *tree_row_in_descendants(GtkWidget *widget) { + if (widget == NULL) { + return NULL; + } + + GtkTreeListRow *tree_row = tree_row_data(widget); + if (tree_row != NULL) { + return tree_row; + } + + for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkTreeListRow *child_row = tree_row_in_descendants(child); + if (child_row != NULL) { + return child_row; + } + } + + return NULL; +} + +static GtkTreeListRow *tree_row_in_ancestors(GtkWidget *widget) { + for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { + GtkTreeListRow *tree_row = tree_row_data(ancestor); + if (tree_row != NULL) { + return tree_row; + } + } + + return NULL; +} + +static GtkTreeListRow *tree_row_for_picked_widget(GtkWidget *widget) { + gboolean picked_list_item = FALSE; + + for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { + if (g_strcmp0(G_OBJECT_TYPE_NAME(ancestor), "GtkListItemWidget") == 0) { + picked_list_item = TRUE; + break; + } + } + + if (!picked_list_item) { + return NULL; + } + + GtkTreeListRow *tree_row = tree_row_in_ancestors(widget); + + return tree_row == NULL ? tree_row_in_descendants(widget) : tree_row; +} + +static gboolean file_tree_model_index_for_path(GtkTreeListModel *tree_model, const char *path, guint *position) { + if (tree_model == NULL || path == NULL) { + return FALSE; + } + + guint row_count = g_list_model_get_n_items(G_LIST_MODEL(tree_model)); + for (guint index = 0; index < row_count; index++) { + GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(tree_model), index); + if (row == NULL) { + continue; + } + + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); + gboolean matches = g_strcmp0(item->path, path) == 0; + g_object_unref(row); + + if (matches) { + *position = index; + return TRUE; + } + } + + return FALSE; +} + +static void file_tree_context_select_focused_path(FileTreeContext *context) { + if (context->list == NULL || context->focused_path == NULL) { + return; + } + + GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); + guint position = 0; + if (file_tree_model_index_for_path(context->tree_model, context->focused_path, &position)) { + gtk_selection_model_select_item(selection, position, TRUE); + } +} + +static void file_tree_clicked(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)n_press; + + FileTreeContext *context = user_data; + GdkModifierType state = gtk_event_controller_get_current_event_state(GTK_EVENT_CONTROLLER(gesture)); + gboolean multi_select_click = + (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK)) != 0; + + if (context->list == NULL) { + return; + } + + GtkWidget *source = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + GtkTreeListRow *tree_row = NULL; + + if (gtk_widget_compute_point(source, context->list, &source_point, &list_point)) { + GtkWidget *picked = gtk_widget_pick(context->list, list_point.x, list_point.y, GTK_PICK_DEFAULT); + tree_row = tree_row_for_picked_widget(picked); + } + + if (tree_row != NULL) { + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); + if (!kosmos_file_tree_item_is_message(item)) { + file_tree_context_set_focused_path(context, item->path); + } + if (!multi_select_click && gtk_tree_list_row_is_expandable(tree_row)) { + gtk_tree_list_row_set_expanded(tree_row, !gtk_tree_list_row_get_expanded(tree_row)); + } + } + + g_clear_object(&tree_row); +} + +static GtkWindow *file_tree_context_window(FileTreeContext *context) { + GtkRoot *root = gtk_widget_get_root(context->root); + + return GTK_IS_WINDOW(root) ? GTK_WINDOW(root) : NULL; +} + +static gboolean file_tree_item_is_root(FileTreeContext *context, KosmosFileTreeItem *item) { + return context->root_path != NULL && g_strcmp0(context->root_path, item->path) == 0; +} + +static void apply_file_tree_operation_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix) { + file_tree_context_apply_response(context, tree, error, prefix); + g_clear_error(&error); + if (tree != NULL) { + json_node_unref(tree); + } +} + +static gboolean path_is_equal_or_descendant(const char *path, const char *parent) { + gsize parent_len = strlen(parent); + + return g_strcmp0(path, parent) == 0 || + (g_str_has_prefix(path, parent) && G_IS_DIR_SEPARATOR(path[parent_len])); +} + +static gboolean file_tree_entry_refs_contain_ancestor(GPtrArray *entries, const char *path) { + for (guint index = 0; index < entries->len; index++) { + FileTreeEntryRef *entry = g_ptr_array_index(entries, index); + if (path_is_equal_or_descendant(path, entry->path)) { + return TRUE; + } + } + + return FALSE; +} + +static void file_tree_entry_refs_remove_descendants(GPtrArray *entries, const char *path) { + for (guint index = entries->len; index > 0; index--) { + FileTreeEntryRef *entry = g_ptr_array_index(entries, index - 1); + if (path_is_equal_or_descendant(entry->path, path)) { + g_ptr_array_remove_index(entries, index - 1); + } + } +} + +static void file_tree_entry_refs_add_filtered(GPtrArray *entries, KosmosFileTreeItem *item) { + if (file_tree_entry_refs_contain_ancestor(entries, item->path)) { + return; + } + + file_tree_entry_refs_remove_descendants(entries, item->path); + g_ptr_array_add(entries, file_tree_entry_ref_new_from_item(item)); +} + +static GPtrArray *file_tree_context_selected_entries(FileTreeContext *context, gboolean include_root) { + GPtrArray *entries = file_tree_entry_ref_array_new(); + + if (context->list == NULL || context->tree_model == NULL) { + return entries; + } + + GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); + guint row_count = g_list_model_get_n_items(G_LIST_MODEL(context->tree_model)); + for (guint index = 0; index < row_count; index++) { + if (!gtk_selection_model_is_selected(selection, index)) { + continue; + } + + GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(context->tree_model), index); + if (row == NULL) { + continue; + } + + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); + if (!kosmos_file_tree_item_is_message(item) && (include_root || !file_tree_item_is_root(context, item))) { + file_tree_entry_refs_add_filtered(entries, item); + } + + g_object_unref(row); + } + + return entries; +} + +static gboolean file_tree_context_path_is_selected(FileTreeContext *context, const char *path) { + guint position = 0; + + if (context->list == NULL || !file_tree_model_index_for_path(context->tree_model, path, &position)) { + return FALSE; + } + + GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); + return gtk_selection_model_is_selected(selection, position); +} + +static GPtrArray *file_tree_context_action_entries(FileTreeContext *context, KosmosFileTreeItem *item) { + if (!file_tree_item_is_root(context, item) && file_tree_context_path_is_selected(context, item->path)) { + GPtrArray *selected = file_tree_context_selected_entries(context, FALSE); + if (selected->len > 0) { + return selected; + } + + g_ptr_array_unref(selected); + } + + GPtrArray *entries = file_tree_entry_ref_array_new(); + if (!file_tree_item_is_root(context, item)) { + g_ptr_array_add(entries, file_tree_entry_ref_new_from_item(item)); + } + return entries; +} + +static gboolean file_tree_item_can_drag(FileTreeContext *context, KosmosFileTreeItem *item) { + return item != NULL && !kosmos_file_tree_item_is_message(item) && !file_tree_item_is_root(context, item); +} + +static gboolean file_tree_item_can_drop_on( + FileTreeContext *context, + KosmosFileTreeItem *source, + KosmosFileTreeItem *target +) { + if (!file_tree_item_can_drag(context, source) || target == NULL || !kosmos_file_tree_item_is_directory(target)) { + return FALSE; + } + + char *source_parent = g_path_get_dirname(source->path); + gboolean same_parent = g_strcmp0(source_parent, target->path) == 0; + g_free(source_parent); + + if (same_parent || g_strcmp0(source->path, target->path) == 0) { + return FALSE; + } + + return !kosmos_file_tree_item_is_directory(source) || !path_is_equal_or_descendant(target->path, source->path); +} + +static GdkContentProvider *file_tree_drag_prepare(GtkDragSource *source, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(source)); + FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); + KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(row), "file-tree-item"); + + if (context == NULL || !file_tree_item_can_drag(context, item)) { + return NULL; + } + + return gdk_content_provider_new_typed(KOSMOS_TYPE_FILE_TREE_ITEM, item); +} + +static KosmosFileTreeItem *file_tree_drop_source_item(GtkDropTarget *target) { + const GValue *value = gtk_drop_target_get_value(target); + + return value == NULL ? NULL : g_value_get_object(value); +} + +static GdkDragAction file_tree_drop_highlight(GtkDropTarget *target) { + GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); + FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); + KosmosFileTreeItem *source = file_tree_drop_source_item(target); + KosmosFileTreeItem *target_item = g_object_get_data(G_OBJECT(row), "file-tree-item"); + gboolean valid = context != NULL && file_tree_item_can_drop_on(context, source, target_item); + + if (valid) { + gtk_widget_add_css_class(row, "file-tree-drop-target"); + return GDK_ACTION_MOVE; + } + + gtk_widget_remove_css_class(row, "file-tree-drop-target"); + return 0; +} + +static GdkDragAction file_tree_drop_enter(GtkDropTarget *target, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + return file_tree_drop_highlight(target); +} + +static GdkDragAction file_tree_drop_motion(GtkDropTarget *target, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + return file_tree_drop_highlight(target); +} + +static void file_tree_drop_leave(GtkDropTarget *target, gpointer user_data) { + (void)user_data; + + GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); + gtk_widget_remove_css_class(row, "file-tree-drop-target"); +} + +static gboolean file_tree_drop(GtkDropTarget *target, const GValue *value, double x, double y, gpointer user_data) { + (void)x; + (void)y; + (void)user_data; + + GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); + FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); + KosmosFileTreeItem *source = g_value_get_object(value); + KosmosFileTreeItem *target_item = g_object_get_data(G_OBJECT(row), "file-tree-item"); + gtk_widget_remove_css_class(row, "file-tree-drop-target"); + + if (context == NULL || !file_tree_item_can_drop_on(context, source, target_item)) { + return FALSE; + } + + char *focused_path = file_tree_path_child(target_item->path, source->name); + FileTreeAsyncRequest *request = file_tree_async_request_new(context, FILE_TREE_ASYNC_MOVE, "Failed to move entry"); + request->path = g_strdup(source->path); + request->target_directory_path = g_strdup(target_item->path); + request->pending_focus_path = g_strdup(focused_path); + request->show_progress = TRUE; + file_tree_async_request_start(context, request, "Moving entry..."); + g_free(focused_path); + return TRUE; +} + +static void inline_rename_commit(GtkWidget *entry) { + FileTreeContext *context = g_object_get_data(G_OBJECT(entry), "file-tree-context"); + KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(entry), "file-tree-item"); + + if (context == NULL || item == NULL || g_strcmp0(context->editing_path, item->path) != 0) { + return; + } + + const char *name = gtk_editable_get_text(GTK_EDITABLE(entry)); + if (name == NULL || name[0] == '\0' || g_strcmp0(name, item->name) == 0) { + inline_rename_cancel(entry); + return; + } + + char *parent = file_tree_path_parent(item->path); + char *focused_path = file_tree_path_child(parent, name); + file_tree_context_set_pending_focus_path(context, focused_path); + file_tree_context_clear_editing_path(context); + + GError *error = NULL; + JsonNode *tree = NULL; + kosmos_ipc_client_rename_file_tree_entry( + context->ipc_client, + context->workspace_id, + item->path, + name, + &tree, + NULL, + &error + ); + apply_file_tree_operation_response(context, tree, error, "Failed to rename entry"); + + g_free(focused_path); + g_free(parent); +} + +static void inline_rename_cancel(GtkWidget *entry) { + FileTreeContext *context = g_object_get_data(G_OBJECT(entry), "file-tree-context"); + KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(entry), "file-tree-item"); + + if (context == NULL || item == NULL || g_strcmp0(context->editing_path, item->path) != 0) { + return; + } + + file_tree_context_clear_editing_path(context); + file_tree_context_refresh(context); +} + +static gboolean inline_rename_key_pressed( + GtkEventControllerKey *controller, + guint keyval, + guint keycode, + GdkModifierType state, + gpointer user_data +) { + (void)keycode; + (void)state; + (void)user_data; + + if (keyval == GDK_KEY_Escape) { + inline_rename_cancel(gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller))); + return TRUE; + } + + return FALSE; +} + +static void inline_rename_focus_leave(GtkEventControllerFocus *controller, gpointer user_data) { + (void)user_data; + + inline_rename_commit(gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller))); +} + +static void file_tree_name_dialog_set_error(FileTreeNameDialog *request, const char *message) { + gtk_label_set_text(GTK_LABEL(request->error_label), message); + gtk_widget_set_visible(request->error_label, TRUE); + gtk_widget_add_css_class(request->entry, "error"); + gtk_widget_grab_focus(request->entry); +} + +static gboolean file_tree_name_dialog_validate(FileTreeNameDialog *request, const char *name) { + if (!file_tree_name_is_valid(name)) { + file_tree_name_dialog_set_error(request, "Enter a valid name."); + return FALSE; + } + + char *target = file_tree_path_child(request->path, name); + gboolean exists = g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK); + g_free(target); + + if (exists) { + file_tree_name_dialog_set_error(request, "An entry with that name already exists."); + return FALSE; + } + + gtk_widget_remove_css_class(request->entry, "error"); + gtk_widget_set_visible(request->error_label, FALSE); + return TRUE; +} + +static void file_tree_name_dialog_entry_changed(GtkEditable *editable, gpointer user_data) { + FileTreeNameDialog *request = user_data; + + gtk_widget_remove_css_class(GTK_WIDGET(editable), "error"); + gtk_widget_set_visible(request->error_label, FALSE); +} + +static void file_tree_name_dialog_accept(GtkWidget *widget, gpointer user_data) { + (void)widget; + + FileTreeNameDialog *request = user_data; + gboolean created = FALSE; + + const char *name = gtk_editable_get_text(GTK_EDITABLE(request->entry)); + GError *error = NULL; + JsonNode *tree = NULL; + + if (!file_tree_name_dialog_validate(request, name)) { + return; + } + + switch (request->operation) { + case FILE_TREE_DIALOG_CREATE_FILE: + { + char *focused_path = file_tree_path_child(request->path, name); + file_tree_context_set_pending_focus_path(request->context, focused_path); + g_free(focused_path); + } + created = kosmos_ipc_client_create_file_tree_entry( + request->context->ipc_client, + request->context->workspace_id, + request->path, + name, + FALSE, + &tree, + NULL, + &error + ); + break; + case FILE_TREE_DIALOG_CREATE_DIRECTORY: + { + char *focused_path = file_tree_path_child(request->path, name); + file_tree_context_set_pending_focus_path(request->context, focused_path); + g_free(focused_path); + } + created = kosmos_ipc_client_create_file_tree_entry( + request->context->ipc_client, + request->context->workspace_id, + request->path, + name, + TRUE, + &tree, + NULL, + &error + ); + break; + } + + if (!created || error != NULL) { + const char *message = error == NULL ? "Failed to create entry." : error->message; + g_clear_pointer(&request->context->pending_focus_path, g_free); + file_tree_name_dialog_set_error(request, message); + g_clear_error(&error); + if (tree != NULL) { + json_node_unref(tree); + } + return; + } + + file_tree_context_apply_tree(request->context, tree); + if (tree != NULL) { + json_node_unref(tree); + } + + adw_dialog_close(request->dialog); +} + +static void file_tree_name_dialog_cancel(GtkWidget *widget, gpointer user_data) { + (void)widget; + + FileTreeNameDialog *request = user_data; + adw_dialog_close(request->dialog); +} + +static void show_name_dialog( + FileTreeContext *context, + FileTreeDialogOperation operation, + const char *path, + const char *initial_name +) { + const char *title = "Name"; + const char *accept = "_Save"; + + if (operation == FILE_TREE_DIALOG_CREATE_FILE) { + title = "New File"; + accept = "_Create"; + } else if (operation == FILE_TREE_DIALOG_CREATE_DIRECTORY) { + title = "New Folder"; + accept = "_Create"; + } + + AdwDialog *dialog = adw_dialog_new(); + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + GtkWidget *entry = gtk_entry_new(); + GtkWidget *error_label = gtk_label_new(NULL); + GtkWidget *buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *cancel_button = gtk_button_new_with_mnemonic("_Cancel"); + GtkWidget *accept_button = gtk_button_new_with_mnemonic(accept); + FileTreeNameDialog *request = g_new0(FileTreeNameDialog, 1); + + request->context = context; + request->operation = operation; + request->path = g_strdup(path); + request->dialog = dialog; + request->entry = entry; + request->error_label = error_label; + + adw_dialog_set_title(dialog, title); + gtk_widget_set_margin_top(content, 18); + gtk_widget_set_margin_bottom(content, 18); + gtk_widget_set_margin_start(content, 18); + gtk_widget_set_margin_end(content, 18); + gtk_editable_set_text(GTK_EDITABLE(entry), initial_name == NULL ? "" : initial_name); + gtk_widget_set_hexpand(entry, TRUE); + gtk_label_set_xalign(GTK_LABEL(error_label), 0.0f); + gtk_label_set_wrap(GTK_LABEL(error_label), TRUE); + gtk_widget_add_css_class(error_label, "error"); + gtk_widget_set_visible(error_label, FALSE); + gtk_widget_set_hexpand(buttons, TRUE); + gtk_widget_set_halign(buttons, GTK_ALIGN_END); + gtk_widget_add_css_class(accept_button, "suggested-action"); + + gtk_box_append(GTK_BOX(content), entry); + gtk_box_append(GTK_BOX(content), error_label); + gtk_box_append(GTK_BOX(buttons), cancel_button); + gtk_box_append(GTK_BOX(buttons), accept_button); + gtk_box_append(GTK_BOX(content), buttons); + adw_dialog_set_child(dialog, content); + g_object_set_data_full(G_OBJECT(dialog), "file-tree-name-request", request, (GDestroyNotify)file_tree_name_dialog_free); + g_signal_connect(entry, "changed", G_CALLBACK(file_tree_name_dialog_entry_changed), request); + g_signal_connect(entry, "activate", G_CALLBACK(file_tree_name_dialog_accept), request); + g_signal_connect(cancel_button, "clicked", G_CALLBACK(file_tree_name_dialog_cancel), request); + g_signal_connect(accept_button, "clicked", G_CALLBACK(file_tree_name_dialog_accept), request); + adw_dialog_present(dialog, context->root); + gtk_widget_grab_focus(entry); +} + +static void file_tree_delete_dialog_response(AdwAlertDialog *dialog, GAsyncResult *result, gpointer user_data) { + (void)dialog; + + FileTreeDeleteDialog *request = user_data; + FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); + const char *response = adw_alert_dialog_choose_finish(dialog, result); + + if (context != NULL && g_strcmp0(response, "delete") == 0) { + FileTreeEntryRef *first_entry = g_ptr_array_index(request->entries, 0); + char *focused_path = file_tree_path_parent(first_entry->path); + FileTreeAsyncRequest *async_request = file_tree_async_request_new(context, FILE_TREE_ASYNC_DELETE, "Failed to delete entry"); + + async_request->entries = file_tree_entry_ref_array_copy(request->entries); + async_request->pending_focus_path = g_strdup(focused_path); + async_request->show_progress = TRUE; + async_request->clear_clipboard_on_success = file_tree_context_clipboard_intersects_entries(context, request->entries); + file_tree_async_request_start(context, async_request, request->entries->len == 1 ? "Deleting entry..." : "Deleting entries..."); + g_free(focused_path); + } + + file_tree_delete_dialog_free(request); +} + +static void show_delete_dialog(FileTreeContext *context, GPtrArray *entries) { + if (entries->len == 0) { + return; + } + + FileTreeEntryRef *first_entry = g_ptr_array_index(entries, 0); + const char *type = g_strcmp0(first_entry->kind, "directory") == 0 ? "folder" : "file"; + char *body = entries->len == 1 + ? g_strdup_printf("%s\n\nThis cannot be undone.", first_entry->path) + : g_strdup_printf("Delete %u selected entries?\n\nThis cannot be undone.", entries->len); + AdwDialog *dialog = adw_alert_dialog_new(NULL, body); + FileTreeDeleteDialog *request = g_new0(FileTreeDeleteDialog, 1); + + if (entries->len == 1) { + adw_alert_dialog_format_heading(ADW_ALERT_DIALOG(dialog), "Delete %s \"%s\"?", type, first_entry->name); + } else { + adw_alert_dialog_set_heading(ADW_ALERT_DIALOG(dialog), "Delete Selected Entries?"); + } + adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(dialog), "cancel", "_Cancel", "delete", "_Delete", NULL); + adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(dialog), "cancel"); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "cancel"); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(dialog), "delete", ADW_RESPONSE_DESTRUCTIVE); + + g_free(body); + request->context = context; + request->root = g_object_ref(context->root); + request->entries = file_tree_entry_ref_array_copy(entries); + + adw_alert_dialog_choose(ADW_ALERT_DIALOG(dialog), context->root, NULL, (GAsyncReadyCallback)file_tree_delete_dialog_response, request); +} + +static void reveal_file_tree_entry(FileTreeContext *context, KosmosFileTreeItem *item) { + GFile *file = g_file_new_for_path(item->path); + GFile *target = kosmos_file_tree_item_is_directory(item) ? g_object_ref(file) : g_file_get_parent(file); + + if (target == NULL) { + target = g_object_ref(file); + } + + char *uri = g_file_get_uri(target); + GtkUriLauncher *launcher = gtk_uri_launcher_new(uri); + gtk_uri_launcher_launch(launcher, file_tree_context_window(context), NULL, NULL, NULL); + + g_object_unref(launcher); + g_free(uri); + g_object_unref(target); + g_object_unref(file); +} + +static char *file_tree_menu_parent_path(const char *path, const char *kind) { + return g_strcmp0(kind, "directory") == 0 ? g_strdup(path) : g_path_get_dirname(path); +} + +static FileTreeMenuAction *file_tree_menu_action_new( + FileTreeContext *context, + FileTreeMenuOperation operation, + KosmosFileTreeItem *item, + GtkWidget *popover +) { + FileTreeMenuAction *action = g_new0(FileTreeMenuAction, 1); + + action->context = context; + action->operation = operation; + action->path = g_strdup(item->path); + action->kind = g_strdup(item->kind); + action->name = g_strdup(item->name); + action->entries = file_tree_context_action_entries(context, item); + action->popover = g_object_ref(popover); + + return action; +} + +static void file_tree_menu_button_clicked(GtkButton *button, gpointer user_data) { + (void)button; + + FileTreeMenuAction *action = user_data; + gtk_popover_popdown(GTK_POPOVER(action->popover)); + + switch (action->operation) { + case FILE_TREE_MENU_NEW_FILE: { + char *parent = file_tree_menu_parent_path(action->path, action->kind); + show_name_dialog(action->context, FILE_TREE_DIALOG_CREATE_FILE, parent, ""); + g_free(parent); + break; + } + case FILE_TREE_MENU_NEW_FOLDER: { + char *parent = file_tree_menu_parent_path(action->path, action->kind); + show_name_dialog(action->context, FILE_TREE_DIALOG_CREATE_DIRECTORY, parent, ""); + g_free(parent); + break; + } + case FILE_TREE_MENU_RENAME: + file_tree_context_set_focused_path(action->context, action->path); + file_tree_context_set_editing_path(action->context, action->path); + file_tree_context_refresh(action->context); + break; + case FILE_TREE_MENU_DELETE: + show_delete_dialog(action->context, action->entries); + break; + case FILE_TREE_MENU_REFRESH: + file_tree_context_refresh(action->context); + break; + case FILE_TREE_MENU_CUT: { + file_tree_context_store_clipboard(action->context, action->entries, TRUE); + break; + } + case FILE_TREE_MENU_COPY: { + file_tree_context_store_clipboard(action->context, action->entries, FALSE); + break; + } + case FILE_TREE_MENU_PASTE: { + if (!file_tree_context_has_clipboard(action->context)) { + break; + } + + FileTreeEntryRef *first_clipboard_entry = g_ptr_array_index(action->context->clipboard_entries, 0); + char *target_directory = file_tree_paste_target_directory(action->path, action->kind); + char *conflict_path = file_tree_path_child(target_directory, first_clipboard_entry->name); + gboolean was_cut = action->context->clipboard_cut; + const char *conflict_name = was_cut ? NULL : file_tree_context_clipboard_conflict_name(action->context, target_directory); + char *focused_path = was_cut + ? g_strdup(conflict_path) + : file_tree_available_copy_path(target_directory, first_clipboard_entry->name); + FileTreeAsyncRequest *request = file_tree_async_request_new( + action->context, + was_cut ? FILE_TREE_ASYNC_MOVE : FILE_TREE_ASYNC_COPY, + was_cut ? "Failed to move entry" : "Failed to copy entry" + ); + + request->entries = file_tree_entry_ref_array_copy(action->context->clipboard_entries); + request->target_directory_path = g_strdup(target_directory); + request->pending_focus_path = g_strdup(focused_path); + request->show_progress = TRUE; + request->clear_clipboard_on_success = was_cut; + + if (conflict_name != NULL) { + file_tree_confirm_copy_conflict(action->context, request, conflict_name); + } else { + file_tree_async_request_start(action->context, request, was_cut ? "Moving entries..." : "Copying entries..."); + } + + g_free(focused_path); + g_free(conflict_path); + g_free(target_directory); + break; + } + case FILE_TREE_MENU_REVEAL: { + KosmosFileTreeItem item = { + .name = action->name, + .path = action->path, + .kind = action->kind, + .children = NULL, + }; + reveal_file_tree_entry(action->context, &item); + break; + } + } +} + +static void append_file_tree_menu_item(GMenu *menu, const char *id) { + GMenuItem *menu_item = g_menu_item_new(NULL, NULL); + + g_menu_item_set_attribute(menu_item, "custom", "s", id); + g_menu_append_item(menu, menu_item); + g_object_unref(menu_item); +} + +static GtkWidget *create_file_tree_menu_button( + const char *label_text, + FileTreeContext *context, + FileTreeMenuOperation operation, + KosmosFileTreeItem *item, + GtkWidget *popover +) { + GtkWidget *button = gtk_button_new(); + GtkWidget *label = gtk_label_new(label_text); + FileTreeMenuAction *action = file_tree_menu_action_new(context, operation, item, popover); + + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_widget_set_hexpand(label, TRUE); + gtk_widget_set_halign(label, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(button, TRUE); + gtk_widget_set_halign(button, GTK_ALIGN_FILL); + gtk_widget_add_css_class(button, "flat"); + gtk_button_set_child(GTK_BUTTON(button), label); + g_object_set_data_full(G_OBJECT(button), "file-tree-menu-action", action, (GDestroyNotify)file_tree_menu_action_free); + g_signal_connect(button, "clicked", G_CALLBACK(file_tree_menu_button_clicked), action); + + return button; +} + +static void add_file_tree_menu_button( + GtkPopoverMenu *popover, + const char *id, + const char *label, + FileTreeContext *context, + FileTreeMenuOperation operation, + KosmosFileTreeItem *item +) { + GtkWidget *button = create_file_tree_menu_button(label, context, operation, item, GTK_WIDGET(popover)); + gtk_popover_menu_add_child(popover, button, id); +} + +static GMenu *create_file_tree_menu_model(FileTreeContext *context, KosmosFileTreeItem *item) { + GMenu *menu = g_menu_new(); + GMenu *create_section = g_menu_new(); + GMenu *entry_section = g_menu_new(); + GMenu *clipboard_section = g_menu_new(); + GMenu *location_section = g_menu_new(); + GPtrArray *action_entries = file_tree_context_action_entries(context, item); + gboolean batch = action_entries->len > 1; + + append_file_tree_menu_item(create_section, "new-file"); + append_file_tree_menu_item(create_section, "new-folder"); + g_menu_append_section(menu, NULL, G_MENU_MODEL(create_section)); + + if (!file_tree_item_is_root(context, item)) { + if (!batch) { + append_file_tree_menu_item(entry_section, "rename"); + } + append_file_tree_menu_item(entry_section, "cut"); + append_file_tree_menu_item(entry_section, "copy"); + append_file_tree_menu_item(entry_section, "delete"); + g_menu_append_section(menu, NULL, G_MENU_MODEL(entry_section)); + } + + if (file_tree_context_has_clipboard(context)) { + append_file_tree_menu_item(clipboard_section, "paste"); + g_menu_append_section(menu, NULL, G_MENU_MODEL(clipboard_section)); + } + + append_file_tree_menu_item(location_section, "reveal"); + append_file_tree_menu_item(location_section, "refresh"); + g_menu_append_section(menu, NULL, G_MENU_MODEL(location_section)); + + g_object_unref(create_section); + g_object_unref(entry_section); + g_object_unref(clipboard_section); + g_object_unref(location_section); + g_ptr_array_unref(action_entries); + + return menu; +} + +static void show_file_tree_menu(FileTreeContext *context, KosmosFileTreeItem *item, double x, double y) { + GMenu *menu = create_file_tree_menu_model(context, item); + GtkWidget *popover = gtk_popover_menu_new_from_model(NULL); + gboolean root = file_tree_item_is_root(context, item); + GPtrArray *action_entries = file_tree_context_action_entries(context, item); + gboolean batch = action_entries->len > 1; + char *cut_label = batch ? g_strdup_printf("Cut %u Entries", action_entries->len) : g_strdup("Cut"); + char *copy_label = batch ? g_strdup_printf("Copy %u Entries", action_entries->len) : g_strdup("Copy"); + char *delete_label = batch ? g_strdup_printf("Delete %u Entries", action_entries->len) : g_strdup("Delete"); + GdkRectangle rectangle = { + .x = (int)x, + .y = (int)y, + .width = 1, + .height = 1, + }; + + gtk_popover_menu_set_menu_model(GTK_POPOVER_MENU(popover), G_MENU_MODEL(menu)); + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "new-file", "New File", context, FILE_TREE_MENU_NEW_FILE, item); + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "new-folder", "New Folder", context, FILE_TREE_MENU_NEW_FOLDER, item); + if (!root) { + if (!batch) { + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "rename", "Rename", context, FILE_TREE_MENU_RENAME, item); + } + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "cut", cut_label, context, FILE_TREE_MENU_CUT, item); + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "copy", copy_label, context, FILE_TREE_MENU_COPY, item); + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "delete", delete_label, context, FILE_TREE_MENU_DELETE, item); + } + if (file_tree_context_has_clipboard(context)) { + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "paste", "Paste", context, FILE_TREE_MENU_PASTE, item); + } + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "reveal", "Reveal in Files", context, FILE_TREE_MENU_REVEAL, item); + add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "refresh", "Refresh", context, FILE_TREE_MENU_REFRESH, item); + gtk_widget_set_parent(popover, context->root); + gtk_popover_set_has_arrow(GTK_POPOVER(popover), FALSE); + gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rectangle); + g_signal_connect(popover, "closed", G_CALLBACK(gtk_widget_unparent), NULL); + gtk_popover_popup(GTK_POPOVER(popover)); + g_free(cut_label); + g_free(copy_label); + g_free(delete_label); + g_ptr_array_unref(action_entries); + g_object_unref(menu); +} + +static void show_file_tree_root_menu(FileTreeContext *context, double x, double y) { + if (context->root_path == NULL) { + return; + } + + KosmosFileTreeItem item = { + .name = context->root_name == NULL ? context->root_path : context->root_name, + .path = context->root_path, + .kind = "directory", + .children = NULL, + }; + + show_file_tree_menu(context, &item, x, y); +} + +static void file_tree_context_menu(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { + (void)n_press; + + FileTreeContext *context = user_data; + if (context->list == NULL) { + return; + } + + GtkWidget *source = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + graphene_point_t root_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); + + if (!gtk_widget_compute_point(source, context->list, &source_point, &list_point) || + !gtk_widget_compute_point(source, context->root, &source_point, &root_point)) { + return; + } + + GtkWidget *picked = gtk_widget_pick(context->list, list_point.x, list_point.y, GTK_PICK_DEFAULT); + GtkTreeListRow *tree_row = tree_row_for_picked_widget(picked); + if (tree_row == NULL) { + show_file_tree_root_menu(context, root_point.x, root_point.y); + return; + } + + KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); + if (!kosmos_file_tree_item_is_message(item)) { + gboolean selected = file_tree_context_path_is_selected(context, item->path); + file_tree_context_set_focused_path(context, item->path); + if (!selected) { + file_tree_context_select_focused_path(context); + } + show_file_tree_menu(context, item, root_point.x, root_point.y); + } + + g_object_unref(tree_row); +} + +static void unbind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { + (void)factory; + (void)user_data; + + GtkWidget *hit_row = gtk_list_item_get_child(list_item); + if (hit_row != NULL) { + GtkWidget *entry = g_object_get_data(G_OBJECT(hit_row), "entry-editor"); + + g_object_set_data(G_OBJECT(hit_row), "tree-row", NULL); + g_object_set_data(G_OBJECT(hit_row), "file-tree-context", NULL); + g_object_set_data(G_OBJECT(hit_row), "file-tree-item", NULL); + gtk_widget_remove_css_class(hit_row, "file-tree-drop-target"); + + if (entry != NULL) { + g_object_set_data(G_OBJECT(entry), "file-tree-context", NULL); + g_object_set_data(G_OBJECT(entry), "file-tree-item", NULL); + } + } +} + +static GtkWidget *create_file_tree_list(FileTreeContext *context, JsonObject *root) { + GListStore *root_store = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); + KosmosFileTreeItem *root_item = file_tree_item_from_entry(root); + g_list_store_append(root_store, root_item); + file_tree_context_rebuild_monitors(context, root_item); + g_object_unref(root_item); + + GtkTreeListModel *tree_model = gtk_tree_list_model_new( + G_LIST_MODEL(root_store), + FALSE, + FALSE, + create_child_model, + NULL, + NULL + ); + GtkTreeListRow *root_row = g_list_model_get_item(G_LIST_MODEL(tree_model), 0); + if (root_row != NULL) { + gtk_tree_list_row_set_expanded(root_row, TRUE); + g_object_unref(root_row); } + file_tree_context_restore_expanded(context, tree_model); + context->tree_model = g_object_ref(tree_model); + GtkListItemFactory *factory = gtk_signal_list_item_factory_new(); + g_signal_connect(factory, "setup", G_CALLBACK(setup_list_item), NULL); + g_signal_connect(factory, "bind", G_CALLBACK(bind_list_item), context); + g_signal_connect(factory, "unbind", G_CALLBACK(unbind_list_item), NULL); + + GtkMultiSelection *selection = gtk_multi_selection_new(G_LIST_MODEL(tree_model)); + + guint focused_position = 0; + if (file_tree_model_index_for_path(tree_model, context->focused_path, &focused_position)) { + gtk_selection_model_select_item(GTK_SELECTION_MODEL(selection), focused_position, TRUE); + } + + GtkWidget *list = gtk_list_view_new(GTK_SELECTION_MODEL(selection), factory); + GtkGesture *click = gtk_gesture_click_new(); + GtkGesture *context_menu = gtk_gesture_click_new(); + + gtk_widget_set_margin_bottom(list, 8); + gtk_widget_set_vexpand(list, TRUE); + gtk_widget_set_hexpand(list, TRUE); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), GDK_BUTTON_PRIMARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); + g_signal_connect(click, "pressed", G_CALLBACK(file_tree_clicked), context); + gtk_widget_add_controller(list, GTK_EVENT_CONTROLLER(click)); + + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(context_menu), GDK_BUTTON_SECONDARY); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(context_menu), GTK_PHASE_CAPTURE); + g_signal_connect(context_menu, "pressed", G_CALLBACK(file_tree_context_menu), context); + gtk_widget_add_controller(list, GTK_EVENT_CONTROLLER(context_menu)); + + return list; +} + +static GtkWidget *create_file_tree_root(FileTreeContext *context) { GtkWidget *root_widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *notice_label = gtk_label_new(NULL); GtkWidget *scrolled = gtk_scrolled_window_new(); - GtkWidget *list = create_file_tree_list(root); - GtkGesture *click = gtk_gesture_click_new(); + context->root = root_widget; + context->notice_label = notice_label; + context->scrolled = scrolled; gtk_widget_set_hexpand(root_widget, TRUE); gtk_widget_set_vexpand(root_widget, TRUE); + file_tree_install_css(root_widget); - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); - g_signal_connect(click, "pressed", G_CALLBACK(file_tree_clicked), list); - gtk_widget_add_controller(root_widget, GTK_EVENT_CONTROLLER(click)); + gtk_label_set_xalign(GTK_LABEL(notice_label), 0.0f); + gtk_label_set_wrap(GTK_LABEL(notice_label), TRUE); + gtk_widget_set_margin_top(notice_label, 8); + gtk_widget_set_margin_bottom(notice_label, 4); + gtk_widget_set_margin_start(notice_label, 10); + gtk_widget_set_margin_end(notice_label, 10); + gtk_widget_set_visible(notice_label, FALSE); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), list); gtk_widget_set_hexpand(scrolled, TRUE); gtk_widget_set_vexpand(scrolled, TRUE); + gtk_box_append(GTK_BOX(root_widget), notice_label); gtk_box_append(GTK_BOX(root_widget), scrolled); + g_object_set_data_full(G_OBJECT(root_widget), "file-tree-context", context, (GDestroyNotify)file_tree_context_free); return root_widget; } @@ -420,22 +2143,11 @@ static GtkWidget *create_tree_view(JsonNode *tree) { GtkWidget *kosmos_file_tree_tab_create(KosmosIpcClient *ipc_client, guint64 workspace_id) { g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(ipc_client), NULL); - GError *error = NULL; - JsonNode *tree = NULL; - if (!kosmos_ipc_client_list_file_tree(ipc_client, workspace_id, &tree, NULL, &error)) { - const char *detail = error == NULL ? "unknown IPC error" : error->message; - char *message = g_strdup_printf("Failed to load file tree: %s", detail); - GtkWidget *status = create_status_label(message, "error"); - - g_free(message); - g_clear_error(&error); - return status; - } - - GtkWidget *view = create_tree_view(tree); - if (tree != NULL) { - json_node_unref(tree); - } + FileTreeContext *context = g_new0(FileTreeContext, 1); + context->ipc_client = g_object_ref(ipc_client); + context->workspace_id = workspace_id; + GtkWidget *view = create_file_tree_root(context); + file_tree_context_refresh(context); return view; } diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs index 4b2a818b..246684c9 100644 --- a/server/src/ipc/messages/file_tree.rs +++ b/server/src/ipc/messages/file_tree.rs @@ -1,4 +1,6 @@ -use core::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind, FileTreeOptions}; +use core::file_tree::{ + FileTree, FileTreeCreateKind, FileTreeEntry, FileTreeEntryKind, FileTreeOptions, +}; use serde::{Deserialize, Serialize}; use super::pane::WorkspaceIdParam; @@ -23,6 +25,111 @@ impl FileTreeParams { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) parent_path: String, + pub(crate) name: String, + pub(crate) kind: FileTreeCreateKindPayload, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl CreateFileTreeEntryParams { + pub(crate) fn options(&self) -> FileTreeOptions { + options(self.max_depth, self.max_entries_per_directory) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RenameFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) path: String, + pub(crate) name: String, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl RenameFileTreeEntryParams { + pub(crate) fn options(&self) -> FileTreeOptions { + options(self.max_depth, self.max_entries_per_directory) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) path: String, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl DeleteFileTreeEntryParams { + pub(crate) fn options(&self) -> FileTreeOptions { + options(self.max_depth, self.max_entries_per_directory) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) path: String, + pub(crate) target_directory_path: String, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl MoveFileTreeEntryParams { + pub(crate) fn options(&self) -> FileTreeOptions { + options(self.max_depth, self.max_entries_per_directory) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CopyFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) path: String, + pub(crate) target_directory_path: String, + max_depth: Option, + max_entries_per_directory: Option, +} + +impl CopyFileTreeEntryParams { + pub(crate) fn options(&self) -> FileTreeOptions { + options(self.max_depth, self.max_entries_per_directory) + } +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FileTreeCreateKindPayload { + File, + Directory, +} + +impl From for FileTreeCreateKind { + fn from(value: FileTreeCreateKindPayload) -> Self { + match value { + FileTreeCreateKindPayload::File => Self::File, + FileTreeCreateKindPayload::Directory => Self::Directory, + } + } +} + +fn options(max_depth: Option, max_entries_per_directory: Option) -> FileTreeOptions { + let defaults = FileTreeOptions::default(); + + FileTreeOptions::new( + max_depth.unwrap_or(defaults.max_depth()), + max_entries_per_directory.unwrap_or(defaults.max_entries_per_directory()), + ) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct FileTreeSnapshot { diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs index a711cb06..d1cc5950 100644 --- a/server/src/ipc/router/file_tree.rs +++ b/server/src/ipc/router/file_tree.rs @@ -1,10 +1,18 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; -use super::super::messages::file_tree::{FileTreeParams, FileTreeSnapshot}; +use super::super::messages::file_tree::{ + CopyFileTreeEntryParams, CreateFileTreeEntryParams, DeleteFileTreeEntryParams, FileTreeParams, + FileTreeSnapshot, MoveFileTreeEntryParams, RenameFileTreeEntryParams, +}; use super::{parse_params, unsupported_action}; pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match request.action.as_str() { "list" => list_file_tree(state, request), + "create" => create_file_tree_entry(state, request), + "rename" => rename_file_tree_entry(state, request), + "delete" => delete_file_tree_entry(state, request), + "move" => move_file_tree_entry(state, request), + "copy" => copy_file_tree_entry(state, request), _ => unsupported_action(request), } } @@ -25,9 +33,119 @@ fn list_file_tree(state: &mut core::State, request: &RequestEnvelope) -> ServerM } } +fn create_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let options = params.options(); + file_tree_response( + request.id, + state.create_file_tree_entry( + params.workspace_id.map(Into::into), + params.parent_path, + ¶ms.name, + params.kind.into(), + options, + ), + ) + } + Err(response) => response, + } +} + +fn rename_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let options = params.options(); + file_tree_response( + request.id, + state.rename_file_tree_entry( + params.workspace_id.map(Into::into), + params.path, + ¶ms.name, + options, + ), + ) + } + Err(response) => response, + } +} + +fn delete_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let options = params.options(); + file_tree_response( + request.id, + state.delete_file_tree_entry( + params.workspace_id.map(Into::into), + params.path, + options, + ), + ) + } + Err(response) => response, + } +} + +fn move_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let options = params.options(); + file_tree_response( + request.id, + state.move_file_tree_entry( + params.workspace_id.map(Into::into), + params.path, + params.target_directory_path, + options, + ), + ) + } + Err(response) => response, + } +} + +fn copy_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let options = params.options(); + file_tree_response( + request.id, + state.copy_file_tree_entry( + params.workspace_id.map(Into::into), + params.path, + params.target_directory_path, + options, + ), + ) + } + Err(response) => response, + } +} + +fn file_tree_response( + request_id: u64, + result: Result, +) -> ServerMessage { + match result { + Ok(tree) => ServerMessage::ok(request_id, FileTreeSnapshot::from_tree(&tree)), + Err(error) => { + ServerMessage::error(request_id, file_tree_error_code(&error), error.to_string()) + } + } +} + fn file_tree_error_code(error: &core::file_tree::FileTreeError) -> &'static str { match error { core::file_tree::FileTreeError::WorkspaceUnavailable => "workspace.not_found", + core::file_tree::FileTreeError::AlreadyExists(_) => "file_tree.already_exists", + core::file_tree::FileTreeError::InvalidCopy => "file_tree.invalid_copy", + core::file_tree::FileTreeError::InvalidMove => "file_tree.invalid_move", + core::file_tree::FileTreeError::InvalidName(_) => "file_tree.invalid_name", + core::file_tree::FileTreeError::InvalidPath(_) => "file_tree.invalid_path", core::file_tree::FileTreeError::Io { .. } => "file_tree.read_failed", + core::file_tree::FileTreeError::NotDirectory(_) => "file_tree.not_directory", + core::file_tree::FileTreeError::OutsideWorkspace(_) => "file_tree.outside_workspace", + core::file_tree::FileTreeError::RootOperation => "file_tree.root_operation", } } From d6ec1af8744c567c913605a0cf49a27323dac083 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 03:27:11 -0300 Subject: [PATCH 16/98] chore: setup electron project --- README.md | 6 +- core/src/file_tree.rs | 955 -------- core/src/lib.rs | 1 - core/src/persistence/mod.rs | 4 + core/src/state.rs | 89 - desktop/.gitignore | 34 + desktop/README.md | 64 +- desktop/bun.lock | 854 ++++++++ desktop/components.json | 25 + desktop/meson.build | 41 - desktop/package.json | 38 + desktop/src/app/kosmos_application.c | 53 - desktop/src/app/kosmos_application.h | 13 - desktop/src/components/ui/button.tsx | 58 + desktop/src/ipc/kosmos_ipc_client.c | 407 ---- desktop/src/ipc/kosmos_ipc_client.h | 233 -- desktop/src/ipc/kosmos_ipc_file_tree.c | 179 -- desktop/src/ipc/kosmos_ipc_pane.c | 117 - desktop/src/ipc/kosmos_ipc_protocol.c | 48 - desktop/src/ipc/kosmos_ipc_protocol.h | 47 - desktop/src/ipc/kosmos_ipc_tab.c | 181 -- desktop/src/ipc/kosmos_ipc_workspace.c | 68 - desktop/src/lib/utils.ts | 6 + desktop/src/main.c | 13 - desktop/src/main/main.ts | 93 + desktop/src/main/server-client.ts | 174 ++ desktop/src/preload/preload.ts | 17 + desktop/src/renderer/App.tsx | 12 + desktop/src/renderer/global.d.ts | 9 + desktop/src/renderer/index.css | 130 ++ desktop/src/renderer/index.html | 12 + desktop/src/renderer/ipc/index.ts | 4 + desktop/src/renderer/ipc/pane.ts | 29 + desktop/src/renderer/ipc/tab.ts | 39 + desktop/src/renderer/ipc/transport.ts | 25 + desktop/src/renderer/ipc/workspace.ts | 29 + desktop/src/renderer/main.tsx | 17 + desktop/src/shared/ipc/envelope.ts | 28 + desktop/src/shared/ipc/ids.ts | 4 + desktop/src/shared/ipc/index.ts | 5 + desktop/src/shared/ipc/pane.ts | 50 + desktop/src/shared/ipc/tab.ts | 52 + desktop/src/shared/ipc/workspace.ts | 27 + desktop/src/tabs/kosmos_blank_tab.c | 106 - desktop/src/tabs/kosmos_blank_tab.h | 13 - desktop/src/tabs/kosmos_file_tree_tab.c | 2153 ------------------- desktop/src/tabs/kosmos_file_tree_tab.h | 11 - desktop/src/ui/kosmos_main_window.c | 255 --- desktop/src/ui/kosmos_main_window.h | 16 - desktop/src/ui/kosmos_main_window_private.h | 112 - desktop/src/ui/kosmos_pane_dnd.c | 751 ------- desktop/src/ui/kosmos_pane_layout.c | 605 ------ desktop/src/ui/kosmos_tabbed_pane.c | 755 ------- desktop/src/ui/kosmos_workspace_switcher.c | 365 ---- desktop/tsconfig.json | 24 + desktop/vite.config.ts | 22 + scripts/run.sh | 13 +- server/src/ipc/messages/envelope.rs | 1 - server/src/ipc/messages/file_tree.rs | 190 -- server/src/ipc/messages/mod.rs | 1 - server/src/ipc/router/file_tree.rs | 151 -- server/src/ipc/router/mod.rs | 2 - 62 files changed, 1867 insertions(+), 7969 deletions(-) delete mode 100644 core/src/file_tree.rs create mode 100644 desktop/.gitignore create mode 100644 desktop/bun.lock create mode 100644 desktop/components.json delete mode 100644 desktop/meson.build create mode 100644 desktop/package.json delete mode 100644 desktop/src/app/kosmos_application.c delete mode 100644 desktop/src/app/kosmos_application.h create mode 100644 desktop/src/components/ui/button.tsx delete mode 100644 desktop/src/ipc/kosmos_ipc_client.c delete mode 100644 desktop/src/ipc/kosmos_ipc_client.h delete mode 100644 desktop/src/ipc/kosmos_ipc_file_tree.c delete mode 100644 desktop/src/ipc/kosmos_ipc_pane.c delete mode 100644 desktop/src/ipc/kosmos_ipc_protocol.c delete mode 100644 desktop/src/ipc/kosmos_ipc_protocol.h delete mode 100644 desktop/src/ipc/kosmos_ipc_tab.c delete mode 100644 desktop/src/ipc/kosmos_ipc_workspace.c create mode 100644 desktop/src/lib/utils.ts delete mode 100644 desktop/src/main.c create mode 100644 desktop/src/main/main.ts create mode 100644 desktop/src/main/server-client.ts create mode 100644 desktop/src/preload/preload.ts create mode 100644 desktop/src/renderer/App.tsx create mode 100644 desktop/src/renderer/global.d.ts create mode 100644 desktop/src/renderer/index.css create mode 100644 desktop/src/renderer/index.html create mode 100644 desktop/src/renderer/ipc/index.ts create mode 100644 desktop/src/renderer/ipc/pane.ts create mode 100644 desktop/src/renderer/ipc/tab.ts create mode 100644 desktop/src/renderer/ipc/transport.ts create mode 100644 desktop/src/renderer/ipc/workspace.ts create mode 100644 desktop/src/renderer/main.tsx create mode 100644 desktop/src/shared/ipc/envelope.ts create mode 100644 desktop/src/shared/ipc/ids.ts create mode 100644 desktop/src/shared/ipc/index.ts create mode 100644 desktop/src/shared/ipc/pane.ts create mode 100644 desktop/src/shared/ipc/tab.ts create mode 100644 desktop/src/shared/ipc/workspace.ts delete mode 100644 desktop/src/tabs/kosmos_blank_tab.c delete mode 100644 desktop/src/tabs/kosmos_blank_tab.h delete mode 100644 desktop/src/tabs/kosmos_file_tree_tab.c delete mode 100644 desktop/src/tabs/kosmos_file_tree_tab.h delete mode 100644 desktop/src/ui/kosmos_main_window.c delete mode 100644 desktop/src/ui/kosmos_main_window.h delete mode 100644 desktop/src/ui/kosmos_main_window_private.h delete mode 100644 desktop/src/ui/kosmos_pane_dnd.c delete mode 100644 desktop/src/ui/kosmos_pane_layout.c delete mode 100644 desktop/src/ui/kosmos_tabbed_pane.c delete mode 100644 desktop/src/ui/kosmos_workspace_switcher.c create mode 100644 desktop/tsconfig.json create mode 100644 desktop/vite.config.ts delete mode 100644 server/src/ipc/messages/file_tree.rs delete mode 100644 server/src/ipc/router/file_tree.rs diff --git a/README.md b/README.md index 9adfb555..470ae873 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,16 @@ Kosmos only runs on Linux. Windows and macOS are not supported, and support for - `core/` contains the Rust logic layer. - `server/` contains the Rust backend process that imports `core`. -- `desktop/` contains the C/GTK frontend. +- `desktop/` contains the Bun/Electron frontend. - `assets/` contains shared branding and icon assets. ## Development - Run the app with `./scripts/run.sh`. - Test the Rust workspace with `cargo test --workspace`. -- Build the GTK desktop app with `meson setup desktop/build desktop` and `meson compile -C desktop/build`. +- Build the Electron desktop app with `bun install --cwd desktop` and `bun run --cwd desktop build`. -The server and desktop frontend communicate over a Unix socket. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. +The server and Electron main process communicate over a Unix socket. The renderer communicates with Electron main through Electron IPC only. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. ## License diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs deleted file mode 100644 index 70407708..00000000 --- a/core/src/file_tree.rs +++ /dev/null @@ -1,955 +0,0 @@ -use std::error::Error as StdError; -use std::fmt; -use std::fs::{self, FileType}; -use std::io; -use std::path::{Path, PathBuf}; - -pub const DEFAULT_FILE_TREE_MAX_DEPTH: usize = 6; -pub const DEFAULT_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY: usize = 500; -pub const MAX_FILE_TREE_MAX_DEPTH: usize = 16; -pub const MAX_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY: usize = 5_000; - -const DEFAULT_FILE_TREE_MAX_TOTAL_ENTRIES: usize = 10_000; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct FileTreeOptions { - max_depth: usize, - max_entries_per_directory: usize, -} - -impl FileTreeOptions { - pub fn new(max_depth: usize, max_entries_per_directory: usize) -> Self { - Self { - max_depth: max_depth.min(MAX_FILE_TREE_MAX_DEPTH), - max_entries_per_directory: max_entries_per_directory - .clamp(1, MAX_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY), - } - } - - pub fn max_depth(self) -> usize { - self.max_depth - } - - pub fn max_entries_per_directory(self) -> usize { - self.max_entries_per_directory - } -} - -impl Default for FileTreeOptions { - fn default() -> Self { - Self { - max_depth: DEFAULT_FILE_TREE_MAX_DEPTH, - max_entries_per_directory: DEFAULT_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FileTree { - root: FileTreeEntry, -} - -impl FileTree { - pub fn read(root: impl AsRef, options: FileTreeOptions) -> Result { - let root = root.as_ref().to_path_buf(); - let file_type = fs::symlink_metadata(&root) - .map_err(|error| FileTreeError::io(root.clone(), error))? - .file_type(); - let mut remaining_entries = DEFAULT_FILE_TREE_MAX_TOTAL_ENTRIES; - - Ok(Self { - root: read_entry(root, file_type, 0, options, &mut remaining_entries), - }) - } - - pub fn root(&self) -> &FileTreeEntry { - &self.root - } -} - -pub fn create_entry( - root: impl AsRef, - parent: impl AsRef, - name: &str, - kind: FileTreeCreateKind, -) -> Result { - validate_entry_name(name)?; - - let root = canonical_workspace_root(root.as_ref())?; - let parent = resolve_existing_directory(&root, parent.as_ref())?; - let target = parent.join(name); - - if path_exists(&target) { - return Err(FileTreeError::AlreadyExists(target)); - } - - match kind { - FileTreeCreateKind::File => fs::File::create(&target) - .map(|_| ()) - .map_err(|error| FileTreeError::io(target.clone(), error))?, - FileTreeCreateKind::Directory => { - fs::create_dir(&target).map_err(|error| FileTreeError::io(target.clone(), error))? - } - } - - Ok(target) -} - -pub fn rename_entry(root: impl AsRef, path: impl AsRef, name: &str) -> Result { - validate_entry_name(name)?; - - let root = canonical_workspace_root(root.as_ref())?; - let source = resolve_existing_entry(&root, path.as_ref())?; - reject_workspace_root_operation(&root, &source)?; - let target = source - .parent() - .expect("resolved entries must have a parent") - .join(name); - - if path_exists(&target) { - return Err(FileTreeError::AlreadyExists(target)); - } - - fs::rename(&source, &target).map_err(|error| FileTreeError::io(source, error))?; - Ok(target) -} - -pub fn delete_entry(root: impl AsRef, path: impl AsRef) -> Result<()> { - let root = canonical_workspace_root(root.as_ref())?; - let path = resolve_existing_entry(&root, path.as_ref())?; - reject_workspace_root_operation(&root, &path)?; - let file_type = fs::symlink_metadata(&path) - .map_err(|error| FileTreeError::io(path.clone(), error))? - .file_type(); - - if file_type.is_dir() { - fs::remove_dir_all(&path).map_err(|error| FileTreeError::io(path, error)) - } else { - fs::remove_file(&path).map_err(|error| FileTreeError::io(path, error)) - } -} - -pub fn move_entry( - root: impl AsRef, - path: impl AsRef, - target_directory: impl AsRef, -) -> Result { - let root = canonical_workspace_root(root.as_ref())?; - let source = resolve_existing_entry(&root, path.as_ref())?; - reject_workspace_root_operation(&root, &source)?; - let target_directory = resolve_existing_directory(&root, target_directory.as_ref())?; - let target = target_directory.join( - source - .file_name() - .ok_or_else(|| FileTreeError::InvalidPath(source.clone()))?, - ); - - if source == target { - return Ok(source); - } - if source_is_directory(&source)? && target_directory.starts_with(&source) { - return Err(FileTreeError::InvalidMove); - } - if path_exists(&target) { - return Err(FileTreeError::AlreadyExists(target)); - } - - fs::rename(&source, &target).map_err(|error| FileTreeError::io(source, error))?; - Ok(target) -} - -pub fn copy_entry( - root: impl AsRef, - path: impl AsRef, - target_directory: impl AsRef, -) -> Result { - let root = canonical_workspace_root(root.as_ref())?; - let source = resolve_existing_entry(&root, path.as_ref())?; - reject_workspace_root_operation(&root, &source)?; - let target_directory = resolve_existing_directory(&root, target_directory.as_ref())?; - - if source_is_directory(&source)? && target_directory.starts_with(&source) { - return Err(FileTreeError::InvalidCopy); - } - - let target = available_copy_target(&source, &target_directory)?; - - copy_entry_to(&source, &target)?; - Ok(target) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FileTreeEntry { - name: String, - path: PathBuf, - kind: FileTreeEntryKind, - children: Vec, - children_truncated: bool, - read_error: Option, -} - -impl FileTreeEntry { - fn new(name: String, path: PathBuf, kind: FileTreeEntryKind) -> Self { - Self { - name, - path, - kind, - children: Vec::new(), - children_truncated: false, - read_error: None, - } - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub fn kind(&self) -> FileTreeEntryKind { - self.kind - } - - pub fn children(&self) -> &[FileTreeEntry] { - &self.children - } - - pub fn children_truncated(&self) -> bool { - self.children_truncated - } - - pub fn read_error(&self) -> Option<&str> { - self.read_error.as_deref() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FileTreeEntryKind { - Directory, - File, - Symlink, - Other, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FileTreeCreateKind { - File, - Directory, -} - -#[derive(Debug)] -pub enum FileTreeError { - WorkspaceUnavailable, - AlreadyExists(PathBuf), - InvalidCopy, - InvalidMove, - InvalidName(String), - InvalidPath(PathBuf), - Io { path: PathBuf, source: io::Error }, - NotDirectory(PathBuf), - OutsideWorkspace(PathBuf), - RootOperation, -} - -impl FileTreeError { - fn io(path: PathBuf, source: io::Error) -> Self { - Self::Io { path, source } - } -} - -impl fmt::Display for FileTreeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::WorkspaceUnavailable => formatter.write_str("workspace is not available"), - Self::AlreadyExists(path) => write!(formatter, "{} already exists", path.display()), - Self::InvalidCopy => formatter.write_str("entry cannot be copied there"), - Self::InvalidMove => formatter.write_str("entry cannot be moved there"), - Self::InvalidName(name) => write!(formatter, "invalid file name {name:?}"), - Self::InvalidPath(path) => write!(formatter, "invalid path {}", path.display()), - Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()), - Self::NotDirectory(path) => write!(formatter, "{} is not a directory", path.display()), - Self::OutsideWorkspace(path) => { - write!(formatter, "{} is outside the workspace", path.display()) - } - Self::RootOperation => formatter.write_str("workspace root cannot be changed"), - } - } -} - -impl StdError for FileTreeError { - fn source(&self) -> Option<&(dyn StdError + 'static)> { - match self { - Self::WorkspaceUnavailable => None, - Self::AlreadyExists(_) => None, - Self::InvalidCopy => None, - Self::InvalidMove => None, - Self::InvalidName(_) => None, - Self::InvalidPath(_) => None, - Self::Io { source, .. } => Some(source), - Self::NotDirectory(_) => None, - Self::OutsideWorkspace(_) => None, - Self::RootOperation => None, - } - } -} - -pub type Result = std::result::Result; - -fn read_entry( - path: PathBuf, - file_type: FileType, - depth: usize, - options: FileTreeOptions, - remaining_entries: &mut usize, -) -> FileTreeEntry { - let kind = entry_kind(file_type); - let name = entry_name(&path); - - read_entry_from_parts(path, name, kind, depth, options, remaining_entries) -} - -fn canonical_workspace_root(root: &Path) -> Result { - fs::canonicalize(root).map_err(|error| FileTreeError::io(root.to_path_buf(), error)) -} - -fn resolve_existing_entry(root: &Path, path: &Path) -> Result { - if is_workspace_root_path(root, path) { - return Ok(root.to_path_buf()); - } - - let parent = path - .parent() - .ok_or_else(|| FileTreeError::InvalidPath(path.to_path_buf()))?; - let parent = - fs::canonicalize(parent).map_err(|error| FileTreeError::io(parent.to_path_buf(), error))?; - - ensure_in_workspace(root, &parent)?; - - let file_name = path - .file_name() - .ok_or_else(|| FileTreeError::InvalidPath(path.to_path_buf()))?; - let resolved = parent.join(file_name); - - fs::symlink_metadata(&resolved).map_err(|error| FileTreeError::io(resolved.clone(), error))?; - Ok(resolved) -} - -fn is_workspace_root_path(root: &Path, path: &Path) -> bool { - if path == root { - return true; - } - - let Some(root_name) = root.file_name() else { - return false; - }; - let Some(path_name) = path.file_name() else { - return false; - }; - if path_name != root_name { - return false; - } - - let Some(root_parent) = root.parent() else { - return false; - }; - let Some(path_parent) = path.parent() else { - return false; - }; - - fs::canonicalize(path_parent).is_ok_and(|parent| parent == root_parent) -} - -fn resolve_existing_directory(root: &Path, path: &Path) -> Result { - let path = - fs::canonicalize(path).map_err(|error| FileTreeError::io(path.to_path_buf(), error))?; - - ensure_in_workspace(root, &path)?; - if !path.is_dir() { - return Err(FileTreeError::NotDirectory(path)); - } - - Ok(path) -} - -fn ensure_in_workspace(root: &Path, path: &Path) -> Result<()> { - if path == root || path.starts_with(root) { - Ok(()) - } else { - Err(FileTreeError::OutsideWorkspace(path.to_path_buf())) - } -} - -fn reject_workspace_root_operation(root: &Path, path: &Path) -> Result<()> { - if path == root { - Err(FileTreeError::RootOperation) - } else { - Ok(()) - } -} - -fn validate_entry_name(name: &str) -> Result<()> { - let invalid = name.is_empty() - || name == "." - || name == ".." - || name.contains(std::path::MAIN_SEPARATOR) - || name.contains('/'); - - if invalid { - Err(FileTreeError::InvalidName(name.to_owned())) - } else { - Ok(()) - } -} - -fn source_is_directory(path: &Path) -> Result { - Ok(fs::symlink_metadata(path) - .map_err(|error| FileTreeError::io(path.to_path_buf(), error))? - .file_type() - .is_dir()) -} - -fn path_exists(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok() -} - -fn available_copy_target(source: &Path, target_directory: &Path) -> Result { - let file_name = source - .file_name() - .ok_or_else(|| FileTreeError::InvalidPath(source.to_path_buf()))?; - let target = target_directory.join(file_name); - - if target != source && !path_exists(&target) { - return Ok(target); - } - - let file_name = file_name.to_string_lossy(); - let mut index = 1; - - loop { - let target = target_directory.join(numbered_copy_name(&file_name, index)); - if target != source && !path_exists(&target) { - return Ok(target); - } - index += 1; - } -} - -fn numbered_copy_name(file_name: &str, index: usize) -> String { - match file_name.rfind('.') { - Some(dot_index) if dot_index > 0 && dot_index + 1 < file_name.len() => { - let (name, extension) = file_name.split_at(dot_index); - format!("{name} ({index}){extension}") - } - _ => format!("{file_name} ({index})"), - } -} - -fn copy_entry_to(source: &Path, target: &Path) -> Result<()> { - let file_type = fs::symlink_metadata(source) - .map_err(|error| FileTreeError::io(source.to_path_buf(), error))? - .file_type(); - - if file_type.is_dir() { - copy_directory_to(source, target) - } else if file_type.is_symlink() { - copy_symlink_to(source, target) - } else { - fs::copy(source, target) - .map(|_| ()) - .map_err(|error| FileTreeError::io(source.to_path_buf(), error)) - } -} - -fn copy_directory_to(source: &Path, target: &Path) -> Result<()> { - fs::create_dir(target).map_err(|error| FileTreeError::io(target.to_path_buf(), error))?; - - for child in - fs::read_dir(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))? - { - let child = child.map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; - let child_source = child.path(); - let child_target = target.join(child.file_name()); - - if path_exists(&child_target) { - return Err(FileTreeError::AlreadyExists(child_target)); - } - - copy_entry_to(&child_source, &child_target)?; - } - - Ok(()) -} - -#[cfg(unix)] -fn copy_symlink_to(source: &Path, target: &Path) -> Result<()> { - let link_target = - fs::read_link(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; - std::os::unix::fs::symlink(link_target, target) - .map_err(|error| FileTreeError::io(target.to_path_buf(), error)) -} - -#[cfg(windows)] -fn copy_symlink_to(source: &Path, target: &Path) -> Result<()> { - let link_target = - fs::read_link(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; - let metadata = - fs::metadata(source).map_err(|error| FileTreeError::io(source.to_path_buf(), error))?; - - if metadata.is_dir() { - std::os::windows::fs::symlink_dir(link_target, target) - } else { - std::os::windows::fs::symlink_file(link_target, target) - } - .map_err(|error| FileTreeError::io(target.to_path_buf(), error)) -} - -fn read_entry_from_parts( - path: PathBuf, - name: String, - kind: FileTreeEntryKind, - depth: usize, - options: FileTreeOptions, - remaining_entries: &mut usize, -) -> FileTreeEntry { - let mut entry = FileTreeEntry::new(name, path, kind); - - if kind != FileTreeEntryKind::Directory { - return entry; - } - - if depth >= options.max_depth() { - entry.children_truncated = true; - return entry; - } - - match read_children(entry.path(), depth + 1, options, remaining_entries) { - Ok((children, children_truncated)) => { - entry.children = children; - entry.children_truncated = children_truncated; - } - Err(error) => entry.read_error = Some(error.to_string()), - } - - entry -} - -fn read_children( - directory: &Path, - depth: usize, - options: FileTreeOptions, - remaining_entries: &mut usize, -) -> io::Result<(Vec, bool)> { - if *remaining_entries == 0 { - return Ok((Vec::new(), true)); - } - - let mut children = Vec::new(); - let mut child_entries = Vec::new(); - let mut children_truncated = false; - - for child in fs::read_dir(directory)? { - if child_entries.len() >= options.max_entries_per_directory() { - children_truncated = true; - break; - } - - let Ok(child) = child else { - continue; - }; - let Ok(file_type) = child.file_type() else { - continue; - }; - - let path = child.path(); - let kind = entry_kind(file_type); - child_entries.push(ChildEntry { - name: entry_name(&path), - path, - kind, - }); - } - - child_entries.sort_by(compare_child_entries); - - for child in child_entries { - if *remaining_entries == 0 { - children_truncated = true; - break; - } - - *remaining_entries -= 1; - let ChildEntry { name, path, kind } = child; - children.push(read_entry_from_parts( - path, - name, - kind, - depth, - options, - remaining_entries, - )); - } - - Ok((children, children_truncated)) -} - -struct ChildEntry { - name: String, - path: PathBuf, - kind: FileTreeEntryKind, -} - -fn compare_child_entries(first: &ChildEntry, second: &ChildEntry) -> std::cmp::Ordering { - compare_entry_parts(first.kind, &first.name, second.kind, &second.name) -} - -fn compare_entry_parts( - first_kind: FileTreeEntryKind, - first_name: &str, - second_kind: FileTreeEntryKind, - second_name: &str, -) -> std::cmp::Ordering { - first_kind - .sort_order() - .cmp(&second_kind.sort_order()) - .then_with(|| hidden_sort_order(first_name).cmp(&hidden_sort_order(second_name))) - .then_with(|| first_name.cmp(second_name)) -} - -fn hidden_sort_order(name: &str) -> u8 { - if name.starts_with('.') { 1 } else { 0 } -} - -fn entry_kind(file_type: FileType) -> FileTreeEntryKind { - if file_type.is_dir() { - FileTreeEntryKind::Directory - } else if file_type.is_file() { - FileTreeEntryKind::File - } else if file_type.is_symlink() { - FileTreeEntryKind::Symlink - } else { - FileTreeEntryKind::Other - } -} - -impl FileTreeEntryKind { - fn sort_order(self) -> u8 { - match self { - Self::Directory => 0, - Self::File => 1, - Self::Symlink => 2, - Self::Other => 3, - } - } -} - -fn entry_name(path: &Path) -> String { - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn reads_directories_before_files() { - let root = test_directory("sorted"); - fs::create_dir_all(root.join("src")).expect("directory should be created"); - fs::write(root.join("README.md"), "readme").expect("file should be written"); - fs::write(root.join("src").join("main.rs"), "fn main() {}") - .expect("file should be written"); - - let tree = FileTree::read(&root, FileTreeOptions::new(2, 100)).expect("tree should read"); - - let names = tree - .root() - .children() - .iter() - .map(FileTreeEntry::name) - .collect::>(); - assert_eq!(names, vec!["src", "README.md"]); - assert_eq!(tree.root().children()[0].children()[0].name(), "main.rs"); - - remove_test_directory(root); - } - - #[test] - fn marks_directories_truncated_at_max_depth() { - let root = test_directory("depth"); - fs::create_dir_all(root.join("src").join("nested")).expect("directory should be created"); - - let tree = FileTree::read(&root, FileTreeOptions::new(1, 100)).expect("tree should read"); - let src = &tree.root().children()[0]; - - assert_eq!(src.name(), "src"); - assert!(src.children().is_empty()); - assert!(src.children_truncated()); - - remove_test_directory(root); - } - - #[test] - fn creates_file_and_directory_entries() { - let root = test_directory("create"); - let parent = root.join("src"); - fs::create_dir_all(&parent).expect("directory should be created"); - - let file = create_entry(&root, &parent, "main.rs", FileTreeCreateKind::File) - .expect("file should be created"); - let directory = create_entry(&root, &parent, "nested", FileTreeCreateKind::Directory) - .expect("directory should be created"); - - assert!(file.is_file()); - assert!(directory.is_dir()); - - remove_test_directory(root); - } - - #[test] - fn renames_entries_without_changing_parent() { - let root = test_directory("rename"); - let source = root.join("old.txt"); - fs::write(&source, "old").expect("file should be written"); - - let target = rename_entry(&root, &source, "new.txt").expect("entry should be renamed"); - - assert_eq!(target, root.join("new.txt")); - assert!(!source.exists()); - assert!(target.is_file()); - - remove_test_directory(root); - } - - #[test] - fn deletes_files_and_directories() { - let root = test_directory("delete"); - let file = root.join("delete-me.txt"); - let directory = root.join("delete-me"); - fs::write(&file, "delete").expect("file should be written"); - fs::create_dir_all(directory.join("nested")).expect("directory should be created"); - - delete_entry(&root, &file).expect("file should be deleted"); - delete_entry(&root, &directory).expect("directory should be deleted"); - - assert!(!file.exists()); - assert!(!directory.exists()); - - remove_test_directory(root); - } - - #[test] - fn moves_entries_into_target_directory() { - let root = test_directory("move"); - let source = root.join("main.rs"); - let target_directory = root.join("src"); - fs::write(&source, "fn main() {}").expect("file should be written"); - fs::create_dir_all(&target_directory).expect("directory should be created"); - - let target = move_entry(&root, &source, &target_directory).expect("entry should be moved"); - - assert_eq!(target, target_directory.join("main.rs")); - assert!(!source.exists()); - assert!(target.is_file()); - - remove_test_directory(root); - } - - #[test] - fn copies_files_and_directories_into_target_directory() { - let root = test_directory("copy"); - let file = root.join("main.rs"); - let source_directory = root.join("src"); - let target_directory = root.join("target"); - fs::write(&file, "fn main() {}").expect("file should be written"); - fs::create_dir_all(source_directory.join("nested")).expect("directory should be created"); - fs::write( - source_directory.join("nested").join("lib.rs"), - "pub fn lib() {}", - ) - .expect("file should be written"); - fs::create_dir_all(&target_directory).expect("directory should be created"); - - let copied_file = - copy_entry(&root, &file, &target_directory).expect("file should be copied"); - let copied_directory = copy_entry(&root, &source_directory, &target_directory) - .expect("directory should be copied"); - - assert_eq!(copied_file, target_directory.join("main.rs")); - assert_eq!(copied_directory, target_directory.join("src")); - assert!(file.is_file()); - assert!(copied_file.is_file()); - assert!(copied_directory.join("nested").join("lib.rs").is_file()); - - remove_test_directory(root); - } - - #[test] - fn copies_entries_with_numbered_names_when_target_exists() { - let root = test_directory("copy-numbered"); - let file = root.join("main.rs"); - let directory = root.join("src"); - fs::write(&file, "fn main() {}").expect("file should be written"); - fs::write(root.join("main (1).rs"), "existing").expect("file should be written"); - fs::create_dir_all(directory.join("nested")).expect("directory should be created"); - - let copied_file = copy_entry(&root, &file, &root).expect("file should be copied"); - let copied_directory = - copy_entry(&root, &directory, &root).expect("directory should be copied"); - - assert_eq!(copied_file, root.join("main (2).rs")); - assert_eq!(copied_directory, root.join("src (1)")); - assert!(file.is_file()); - assert!(copied_file.is_file()); - assert!(directory.is_dir()); - assert!(copied_directory.join("nested").is_dir()); - - remove_test_directory(root); - } - - #[cfg(unix)] - #[test] - fn treats_broken_symlink_targets_as_existing() { - let root = test_directory("broken-symlink-targets"); - let source = root.join("source.txt"); - let target_directory = root.join("target"); - let broken = root.join("broken.txt"); - let move_source = root.join("move.txt"); - let move_target = target_directory.join("move.txt"); - let copy_source = root.join("copy.txt"); - let copy_target = target_directory.join("copy.txt"); - - fs::write(&source, "source").expect("file should be written"); - fs::write(&move_source, "move").expect("file should be written"); - fs::write(©_source, "copy").expect("file should be written"); - fs::create_dir_all(&target_directory).expect("directory should be created"); - std::os::unix::fs::symlink(root.join("missing.txt"), &broken) - .expect("symlink should be created"); - std::os::unix::fs::symlink(root.join("missing-move.txt"), &move_target) - .expect("symlink should be created"); - std::os::unix::fs::symlink(root.join("missing-copy.txt"), ©_target) - .expect("symlink should be created"); - - assert!(matches!( - create_entry(&root, &root, "broken.txt", FileTreeCreateKind::File), - Err(FileTreeError::AlreadyExists(_)) - )); - assert!(matches!( - rename_entry(&root, &source, "broken.txt"), - Err(FileTreeError::AlreadyExists(_)) - )); - assert!(matches!( - move_entry(&root, &move_source, &target_directory), - Err(FileTreeError::AlreadyExists(_)) - )); - - let copied = copy_entry(&root, ©_source, &target_directory) - .expect("copy should use a numbered target"); - assert_eq!(copied, target_directory.join("copy (1).txt")); - assert!( - fs::symlink_metadata(copy_target) - .expect("symlink should still exist") - .file_type() - .is_symlink() - ); - - remove_test_directory(root); - } - - #[test] - fn rejects_root_entry_mutations() { - let root = test_directory("root-guard"); - let target_directory = root.join("target"); - fs::create_dir_all(&target_directory).expect("directory should be created"); - - assert!(matches!( - rename_entry(&root, &root, "renamed"), - Err(FileTreeError::RootOperation) - )); - assert!(matches!( - delete_entry(&root, &root), - Err(FileTreeError::RootOperation) - )); - assert!(matches!( - move_entry(&root, &root, &target_directory), - Err(FileTreeError::RootOperation) - )); - assert!(matches!( - copy_entry(&root, &root, &target_directory), - Err(FileTreeError::RootOperation) - )); - - remove_test_directory(root); - } - - #[test] - fn rejects_paths_outside_workspace() { - let root = test_directory("outside-root"); - let outside = test_directory("outside-target"); - let outside_file = outside.join("outside.txt"); - fs::write(&outside_file, "outside").expect("file should be written"); - - assert!(matches!( - create_entry(&root, &outside, "new.txt", FileTreeCreateKind::File), - Err(FileTreeError::OutsideWorkspace(_)) - )); - assert!(matches!( - rename_entry(&root, &outside_file, "renamed.txt"), - Err(FileTreeError::OutsideWorkspace(_)) - )); - - remove_test_directory(root); - remove_test_directory(outside); - } - - #[test] - fn rejects_moving_directory_into_descendant() { - let root = test_directory("move-descendant"); - let source = root.join("src"); - let descendant = source.join("nested"); - fs::create_dir_all(&descendant).expect("directory should be created"); - - assert!(matches!( - move_entry(&root, &source, &descendant), - Err(FileTreeError::InvalidMove) - )); - - remove_test_directory(root); - } - - #[test] - fn rejects_copying_directory_into_descendant() { - let root = test_directory("copy-descendant"); - let source = root.join("src"); - let descendant = source.join("nested"); - fs::create_dir_all(&descendant).expect("directory should be created"); - - assert!(matches!( - copy_entry(&root, &source, &descendant), - Err(FileTreeError::InvalidCopy) - )); - - remove_test_directory(root); - } - - fn test_directory(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after epoch") - .as_nanos(); - let directory = std::env::temp_dir().join(format!( - "kosmos-core-file-tree-{}-{name}-{nanos}", - std::process::id() - )); - - fs::create_dir_all(&directory).expect("test directory should be created"); - directory - } - - fn remove_test_directory(directory: PathBuf) { - let _ = fs::remove_dir_all(directory); - } -} diff --git a/core/src/lib.rs b/core/src/lib.rs index ec1fd6a2..f5aaff37 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,6 +1,5 @@ mod state; -pub mod file_tree; pub mod persistence; pub use state::State; pub mod tree; diff --git a/core/src/persistence/mod.rs b/core/src/persistence/mod.rs index 75202127..911305a9 100644 --- a/core/src/persistence/mod.rs +++ b/core/src/persistence/mod.rs @@ -148,6 +148,10 @@ fn migrate(connection: &Connection) -> Result<()> { UNIQUE (workspace_id, pane_id, position), FOREIGN KEY (workspace_id, pane_id) REFERENCES panes(workspace_id, id) ON DELETE CASCADE ); + + UPDATE tabs + SET kind = 'blank' + WHERE kind NOT IN ('blank', 'file_tree', 'editor', 'git', 'search', 'terminal', 'settings'); ", )?; diff --git a/core/src/state.rs b/core/src/state.rs index 7af70dff..ac2b25d8 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -use crate::file_tree::{FileTree, FileTreeCreateKind, FileTreeError, FileTreeOptions}; use crate::tree::{ Pane, PaneId, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, @@ -46,81 +45,6 @@ impl State { &self.workspaces } - pub fn file_tree( - &self, - workspace_id: Option, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - FileTree::read(workspace.directory(), options) - } - - pub fn create_file_tree_entry( - &self, - workspace_id: Option, - parent: impl AsRef, - name: &str, - kind: FileTreeCreateKind, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - crate::file_tree::create_entry(workspace.directory(), parent, name, kind)?; - FileTree::read(workspace.directory(), options) - } - - pub fn rename_file_tree_entry( - &self, - workspace_id: Option, - path: impl AsRef, - name: &str, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - crate::file_tree::rename_entry(workspace.directory(), path, name)?; - FileTree::read(workspace.directory(), options) - } - - pub fn delete_file_tree_entry( - &self, - workspace_id: Option, - path: impl AsRef, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - crate::file_tree::delete_entry(workspace.directory(), path)?; - FileTree::read(workspace.directory(), options) - } - - pub fn move_file_tree_entry( - &self, - workspace_id: Option, - path: impl AsRef, - target_directory: impl AsRef, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - crate::file_tree::move_entry(workspace.directory(), path, target_directory)?; - FileTree::read(workspace.directory(), options) - } - - pub fn copy_file_tree_entry( - &self, - workspace_id: Option, - path: impl AsRef, - target_directory: impl AsRef, - options: FileTreeOptions, - ) -> Result { - let workspace = self.file_tree_workspace(workspace_id)?; - - crate::file_tree::copy_entry(workspace.directory(), path, target_directory)?; - FileTree::read(workspace.directory(), options) - } - pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { let workspace_id = self.next_workspace_id(); let initial_pane = self.blank_pane(); @@ -362,19 +286,6 @@ impl State { self.workspaces.workspace_mut(workspace_id) } - fn file_tree_workspace( - &self, - workspace_id: Option, - ) -> Result<&Workspace, FileTreeError> { - let workspace_id = self - .resolve_workspace_id(workspace_id) - .ok_or(FileTreeError::WorkspaceUnavailable)?; - - self.workspaces - .workspace(workspace_id) - .ok_or(FileTreeError::WorkspaceUnavailable) - } - fn resolve_workspace_id(&self, workspace_id: Option) -> Option { workspace_id.or_else(|| self.workspaces.active_workspace_id()) } diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/desktop/README.md b/desktop/README.md index 8df5e947..434c9be1 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -1,44 +1,56 @@ # Kosmos Desktop -GTK desktop frontend for Kosmos, written in C and built with Meson. +Electron desktop frontend for Kosmos, managed with Bun, React, Vite, Tailwind CSS, and shadcn/ui with Base UI primitives. ## Requirements -- Meson -- Ninja -- pkg-config -- GTK 4 development files -- JSON-GLib development files -- libadwaita development files +- Bun +- The Rust `kosmos-server` binary from the repository workspace -On Debian or Ubuntu, install the native dependencies with: +## Install -```sh -sudo apt install meson ninja-build pkg-config libgtk-4-dev libjson-glib-dev libadwaita-1-dev +```bash +bun install ``` -## Build +## Development -From the repository root: +From `desktop/`: -```sh -meson setup desktop/build desktop -meson compile -C desktop/build +```bash +bun run build +bun run start ``` -## Run +From the repository root, `./scripts/run.sh` builds the Rust server, starts it, builds the Electron frontend, and launches Electron. -```sh -./desktop/build/kosmos-desktop -``` +## Structure -The root `scripts/run.sh` script also builds the Rust server, starts it, and launches this GTK app. +- `src/main/` owns the Electron main process and Unix socket connection to `server/`. +- `src/preload/` exposes a small, safe renderer API through Electron IPC. +- `src/renderer/` contains the React renderer. +- `src/renderer/ipc/` contains domain functions that renderer code imports to talk to the Rust server. +- `src/components/ui/` contains shadcn/ui components generated for Base UI. +- `src/shared/ipc/` contains protocol envelope and domain types shared across Electron processes. -## Structure +The renderer never talks to the Rust server directly. Renderer consumers should import functions from `src/renderer/ipc/`, which call Electron IPC through the preload API. The main process forwards those calls to the existing newline-delimited JSON protocol on the server Unix socket. -- `src/main.c` bootstraps the GTK application. -- `src/app/` owns GTK application lifecycle and initial server synchronization. -- `src/ui/` owns windows and widgets. -- `src/ipc/` owns Unix socket transport, newline-delimited JSON framing, and protocol helpers for `server/`. +Example: -The IPC client resolves the socket with the same order as the server: `$KOSMOS_SOCKET`, then `$XDG_RUNTIME_DIR/kosmos/server.sock`, then `/tmp/kosmos/server.sock`. +```ts +import { listWorkspaces, openWorkspace } from "@/renderer/ipc/workspace"; + +const workspaces = await listWorkspaces(); +await openWorkspace("/path/to/project"); +``` + +The socket path matches the Rust server lookup order: `$KOSMOS_SOCKET`, then `$XDG_RUNTIME_DIR/kosmos/server.sock`, then `/tmp/kosmos/server.sock`. + +## Scripts + +```bash +bun run build +bun run dev +bun run start +bun run typecheck +``` diff --git a/desktop/bun.lock b/desktop/bun.lock new file mode 100644 index 00000000..2e7754a3 --- /dev/null +++ b/desktop/bun.lock @@ -0,0 +1,854 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "desktop", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.23.0", + "shadcn": "^4.12.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/bun": "latest", + "@types/node": "^26.1.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "electron": "^43.0.0", + "tailwindcss": "^4.3.2", + "typescript": "^5.9.3", + "vite": "^8.1.3", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], + + "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], + + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], + + "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.9", "", {}, "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="], + + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron": ["electron@43.0.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.384", "", {}, "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@4.12.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + + "systeminformation": ["systeminformation@5.31.11", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], + + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "conf/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], + + "cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + + "electron/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/desktop/components.json b/desktop/components.json new file mode 100644 index 00000000..31c48474 --- /dev/null +++ b/desktop/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/renderer/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/desktop/meson.build b/desktop/meson.build deleted file mode 100644 index 78d09f94..00000000 --- a/desktop/meson.build +++ /dev/null @@ -1,41 +0,0 @@ -project( - 'kosmos-desktop', - 'c', - version: '0.1.0', - default_options: [ - 'c_std=c17', - 'warning_level=2', - 'werror=false', - ], -) - -gtk_dep = dependency('gtk4') -gio_unix_dep = dependency('gio-unix-2.0') -json_glib_dep = dependency('json-glib-1.0') -libadwaita_dep = dependency('libadwaita-1') - -sources = files( - 'src/main.c', - 'src/app/kosmos_application.c', - 'src/ipc/kosmos_ipc_client.c', - 'src/ipc/kosmos_ipc_file_tree.c', - 'src/ipc/kosmos_ipc_pane.c', - 'src/ipc/kosmos_ipc_protocol.c', - 'src/ipc/kosmos_ipc_tab.c', - 'src/ipc/kosmos_ipc_workspace.c', - 'src/tabs/kosmos_blank_tab.c', - 'src/tabs/kosmos_file_tree_tab.c', - 'src/ui/kosmos_main_window.c', - 'src/ui/kosmos_pane_dnd.c', - 'src/ui/kosmos_pane_layout.c', - 'src/ui/kosmos_tabbed_pane.c', - 'src/ui/kosmos_workspace_switcher.c', -) - -executable( - 'kosmos-desktop', - sources, - include_directories: include_directories('src'), - dependencies: [gtk_dep, gio_unix_dep, json_glib_dep, libadwaita_dep], - install: true, -) diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 00000000..55ee32ae --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,38 @@ +{ + "name": "kosmos-desktop", + "main": "dist/main.cjs", + "type": "module", + "private": true, + "scripts": { + "build": "bun run clean && bun run build:main && bun run build:preload && bun run build:renderer", + "build:main": "bun build src/main/main.ts --target=node --format=cjs --external electron --outfile dist/main.cjs", + "build:preload": "bun build src/preload/preload.ts --target=node --format=cjs --external electron --outfile dist/preload.cjs", + "build:renderer": "vite build", + "clean": "rm -rf dist", + "dev": "bun run build && electron .", + "start": "electron .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/bun": "latest", + "@types/node": "^26.1.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "electron": "^43.0.0", + "tailwindcss": "^4.3.2", + "typescript": "^5.9.3", + "vite": "^8.1.3" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.23.0", + "shadcn": "^4.12.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0" + } +} diff --git a/desktop/src/app/kosmos_application.c b/desktop/src/app/kosmos_application.c deleted file mode 100644 index 3a16178c..00000000 --- a/desktop/src/app/kosmos_application.c +++ /dev/null @@ -1,53 +0,0 @@ -#include "app/kosmos_application.h" - -#include "ipc/kosmos_ipc_client.h" -#include "ui/kosmos_main_window.h" - -struct _KosmosApplication { - GtkApplication parent_instance; - KosmosIpcClient *ipc_client; -}; - -G_DEFINE_FINAL_TYPE(KosmosApplication, kosmos_application, GTK_TYPE_APPLICATION) - -static void kosmos_application_activate(GApplication *application) { - GtkWindow *active_window = gtk_application_get_active_window(GTK_APPLICATION(application)); - if (active_window != NULL) { - gtk_window_present(active_window); - return; - } - - KosmosApplication *self = KOSMOS_APPLICATION(application); - GtkWidget *window = kosmos_main_window_new(GTK_APPLICATION(application), self->ipc_client); - kosmos_main_window_refresh_workspace_state(KOSMOS_MAIN_WINDOW(window)); - gtk_window_present(GTK_WINDOW(window)); -} - -static void kosmos_application_finalize(GObject *object) { - KosmosApplication *self = KOSMOS_APPLICATION(object); - - g_clear_object(&self->ipc_client); - - G_OBJECT_CLASS(kosmos_application_parent_class)->finalize(object); -} - -static void kosmos_application_class_init(KosmosApplicationClass *klass) { - GApplicationClass *application_class = G_APPLICATION_CLASS(klass); - application_class->activate = kosmos_application_activate; - - GObjectClass *object_class = G_OBJECT_CLASS(klass); - object_class->finalize = kosmos_application_finalize; -} - -static void kosmos_application_init(KosmosApplication *self) { - self->ipc_client = kosmos_ipc_client_new_from_environment(); -} - -KosmosApplication *kosmos_application_new(void) { - return g_object_new( - KOSMOS_TYPE_APPLICATION, - "application-id", "dev.kosmos.Editor", - "flags", (GApplicationFlags)0, - NULL - ); -} diff --git a/desktop/src/app/kosmos_application.h b/desktop/src/app/kosmos_application.h deleted file mode 100644 index ac713f1a..00000000 --- a/desktop/src/app/kosmos_application.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -G_BEGIN_DECLS - -#define KOSMOS_TYPE_APPLICATION (kosmos_application_get_type()) - -G_DECLARE_FINAL_TYPE(KosmosApplication, kosmos_application, KOSMOS, APPLICATION, GtkApplication) - -KosmosApplication *kosmos_application_new(void); - -G_END_DECLS diff --git a/desktop/src/components/ui/button.tsx b/desktop/src/components/ui/button.tsx new file mode 100644 index 00000000..b0336017 --- /dev/null +++ b/desktop/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/desktop/src/ipc/kosmos_ipc_client.c b/desktop/src/ipc/kosmos_ipc_client.c deleted file mode 100644 index ee481563..00000000 --- a/desktop/src/ipc/kosmos_ipc_client.c +++ /dev/null @@ -1,407 +0,0 @@ -#include "ipc/kosmos_ipc_client.h" - -#include -#include - -struct _KosmosIpcClient { - GObject parent_instance; - char *socket_path; - GSocketConnection *connection; - GDataInputStream *input; - GOutputStream *output; - guint64 next_request_id; - GMutex request_mutex; -}; - -G_DEFINE_FINAL_TYPE(KosmosIpcClient, kosmos_ipc_client, G_TYPE_OBJECT) -G_DEFINE_QUARK(kosmos-ipc-error-quark, kosmos_ipc_error) - -static gboolean set_not_connected_error(KosmosIpcClient *self, GError **error) { - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_NOT_CONNECTED, - "IPC client is not connected to %s", - self->socket_path - ); - return FALSE; -} - -static JsonNode *params_to_node(JsonObject *params) { - JsonNode *node = json_node_new(JSON_NODE_OBJECT); - json_node_set_object(node, params); - return node; -} - -static char *serialize_request(guint64 id, KosmosIpcDomain domain, const char *action, JsonObject *params) { - JsonObject *empty_params = NULL; - if (params == NULL) { - empty_params = json_object_new(); - params = empty_params; - } - - JsonBuilder *builder = json_builder_new(); - json_builder_begin_object(builder); - - json_builder_set_member_name(builder, "type"); - json_builder_add_string_value(builder, "request"); - - json_builder_set_member_name(builder, "id"); - json_builder_add_int_value(builder, (gint64)id); - - json_builder_set_member_name(builder, "domain"); - json_builder_add_string_value(builder, kosmos_ipc_domain_to_string(domain)); - - json_builder_set_member_name(builder, "action"); - json_builder_add_string_value(builder, action); - - json_builder_set_member_name(builder, "params"); - json_builder_add_value(builder, params_to_node(params)); - - json_builder_end_object(builder); - - JsonNode *root = json_builder_get_root(builder); - JsonGenerator *generator = json_generator_new(); - json_generator_set_root(generator, root); - char *payload = json_generator_to_data(generator, NULL); - - json_node_unref(root); - g_object_unref(generator); - g_object_unref(builder); - g_clear_pointer(&empty_params, json_object_unref); - - return payload; -} - -static gboolean write_frame(KosmosIpcClient *self, const char *payload, GCancellable *cancellable, GError **error) { - char *frame = g_strconcat(payload, "\n", NULL); - gboolean written = g_output_stream_write_all( - self->output, - frame, - strlen(frame), - NULL, - cancellable, - error - ); - - g_free(frame); - - if (!written || !g_output_stream_flush(self->output, cancellable, error)) { - kosmos_ipc_client_disconnect(self); - return FALSE; - } - - return TRUE; -} - -static char *read_frame(KosmosIpcClient *self, GCancellable *cancellable, GError **error) { - gsize length = 0; - char *line = g_data_input_stream_read_line_utf8(self->input, &length, cancellable, error); - - if (line == NULL && (error == NULL || *error == NULL)) { - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_IO, - "IPC server closed the connection" - ); - } - - if (line == NULL) { - kosmos_ipc_client_disconnect(self); - } - - return line; -} - -static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { - return FALSE; - } - - *value = json_node_get_string(node); - return TRUE; -} - -static gboolean get_uint_member(JsonObject *object, const char *name, guint64 *value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { - return FALSE; - } - - gint64 signed_value = json_node_get_int(node); - if (signed_value < 0) { - return FALSE; - } - - *value = (guint64)signed_value; - return TRUE; -} - -static gboolean get_bool_member(JsonObject *object, const char *name, gboolean *value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_BOOLEAN) { - return FALSE; - } - - *value = json_node_get_boolean(node); - return TRUE; -} - -static gboolean set_invalid_response_error(const char *reason, GError **error) { - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_INVALID_RESPONSE, - "Invalid IPC response: %s", - reason - ); - return FALSE; -} - -static gboolean set_server_response_error(JsonObject *response, GError **error) { - const char *code = "ipc.server_error"; - const char *message = "server returned an IPC error"; - - JsonNode *error_node = json_object_get_member(response, "error"); - if (error_node != NULL && JSON_NODE_HOLDS_OBJECT(error_node)) { - JsonObject *server_error = json_node_get_object(error_node); - get_string_member(server_error, "code", &code); - get_string_member(server_error, "message", &message); - } - - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_SERVER, - "%s: %s", - code, - message - ); - - return FALSE; -} - -static gboolean parse_response(const char *payload, guint64 request_id, JsonNode **result, GError **error) { - JsonParser *parser = json_parser_new(); - GError *parse_error = NULL; - - if (!json_parser_load_from_data(parser, payload, -1, &parse_error)) { - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_INVALID_RESPONSE, - "Invalid IPC response JSON: %s", - parse_error->message - ); - g_clear_error(&parse_error); - g_object_unref(parser); - return FALSE; - } - - JsonNode *root = json_parser_get_root(parser); - if (root == NULL || !JSON_NODE_HOLDS_OBJECT(root)) { - g_object_unref(parser); - return set_invalid_response_error("response root is not an object", error); - } - - JsonObject *response = json_node_get_object(root); - const char *type = NULL; - guint64 response_id = 0; - gboolean ok = FALSE; - - if (!get_string_member(response, "type", &type) || g_strcmp0(type, "response") != 0) { - g_object_unref(parser); - return set_invalid_response_error("missing response type", error); - } - - if (!get_uint_member(response, "id", &response_id) || response_id != request_id) { - g_object_unref(parser); - return set_invalid_response_error("response id does not match request id", error); - } - - if (!get_bool_member(response, "ok", &ok)) { - g_object_unref(parser); - return set_invalid_response_error("missing ok flag", error); - } - - if (!ok) { - gboolean server_error = set_server_response_error(response, error); - g_object_unref(parser); - return server_error; - } - - if (result != NULL) { - JsonNode *result_node = json_object_get_member(response, "result"); - *result = result_node == NULL ? NULL : json_node_copy(result_node); - } - - g_object_unref(parser); - return TRUE; -} - -static void kosmos_ipc_client_finalize(GObject *object) { - KosmosIpcClient *self = KOSMOS_IPC_CLIENT(object); - - kosmos_ipc_client_disconnect(self); - g_mutex_clear(&self->request_mutex); - g_clear_pointer(&self->socket_path, g_free); - - G_OBJECT_CLASS(kosmos_ipc_client_parent_class)->finalize(object); -} - -static void kosmos_ipc_client_class_init(KosmosIpcClientClass *klass) { - GObjectClass *object_class = G_OBJECT_CLASS(klass); - object_class->finalize = kosmos_ipc_client_finalize; -} - -static void kosmos_ipc_client_init(KosmosIpcClient *self) { - self->next_request_id = 1; - g_mutex_init(&self->request_mutex); -} - -char *kosmos_ipc_default_socket_path(void) { - const char *socket_path = g_getenv("KOSMOS_SOCKET"); - if (socket_path != NULL && socket_path[0] != '\0') { - return g_strdup(socket_path); - } - - const char *runtime_dir = g_getenv("XDG_RUNTIME_DIR"); - if (runtime_dir == NULL || runtime_dir[0] == '\0') { - runtime_dir = g_get_tmp_dir(); - } - - return g_build_filename(runtime_dir, "kosmos", "server.sock", NULL); -} - -KosmosIpcClient *kosmos_ipc_client_new(const char *socket_path) { - KosmosIpcClient *self = g_object_new(KOSMOS_TYPE_IPC_CLIENT, NULL); - self->socket_path = socket_path == NULL || socket_path[0] == '\0' - ? kosmos_ipc_default_socket_path() - : g_strdup(socket_path); - - return self; -} - -KosmosIpcClient *kosmos_ipc_client_new_from_environment(void) { - return kosmos_ipc_client_new(NULL); -} - -const char *kosmos_ipc_client_get_socket_path(KosmosIpcClient *self) { - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), NULL); - - return self->socket_path; -} - -gboolean kosmos_ipc_client_is_connected(KosmosIpcClient *self) { - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); - - return self->connection != NULL && !g_io_stream_is_closed(G_IO_STREAM(self->connection)); -} - -gboolean kosmos_ipc_client_connect(KosmosIpcClient *self, GCancellable *cancellable, GError **error) { - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); - - if (kosmos_ipc_client_is_connected(self)) { - return TRUE; - } - - GSocket *socket = g_socket_new( - G_SOCKET_FAMILY_UNIX, - G_SOCKET_TYPE_STREAM, - G_SOCKET_PROTOCOL_DEFAULT, - error - ); - - if (socket == NULL) { - return FALSE; - } - - GSocketAddress *address = g_unix_socket_address_new(self->socket_path); - if (!g_socket_connect(socket, address, cancellable, error)) { - g_object_unref(address); - g_object_unref(socket); - return FALSE; - } - - self->connection = g_socket_connection_factory_create_connection(socket); - g_object_unref(address); - g_object_unref(socket); - - if (self->connection == NULL) { - g_set_error( - error, - KOSMOS_IPC_ERROR, - KOSMOS_IPC_ERROR_CONNECTION, - "failed to create IPC socket connection" - ); - return FALSE; - } - - self->input = g_data_input_stream_new(g_io_stream_get_input_stream(G_IO_STREAM(self->connection))); - g_data_input_stream_set_newline_type(self->input, G_DATA_STREAM_NEWLINE_TYPE_LF); - self->output = g_object_ref(g_io_stream_get_output_stream(G_IO_STREAM(self->connection))); - - return TRUE; -} - -void kosmos_ipc_client_disconnect(KosmosIpcClient *self) { - g_return_if_fail(KOSMOS_IS_IPC_CLIENT(self)); - - if (self->connection != NULL && !g_io_stream_is_closed(G_IO_STREAM(self->connection))) { - g_io_stream_close(G_IO_STREAM(self->connection), NULL, NULL); - } - - g_clear_object(&self->input); - g_clear_object(&self->output); - g_clear_object(&self->connection); -} - -gboolean kosmos_ipc_client_request( - KosmosIpcClient *self, - KosmosIpcDomain domain, - const char *action, - JsonObject *params, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(self), FALSE); - g_return_val_if_fail(action != NULL, FALSE); - - if (result != NULL) { - *result = NULL; - } - - g_mutex_lock(&self->request_mutex); - - if (!kosmos_ipc_client_is_connected(self)) { - gboolean set = set_not_connected_error(self, error); - g_mutex_unlock(&self->request_mutex); - return set; - } - - guint64 request_id = self->next_request_id++; - char *payload = serialize_request(request_id, domain, action, params); - - if (!write_frame(self, payload, cancellable, error)) { - g_free(payload); - g_mutex_unlock(&self->request_mutex); - return FALSE; - } - - g_free(payload); - - char *response = read_frame(self, cancellable, error); - if (response == NULL) { - g_mutex_unlock(&self->request_mutex); - return FALSE; - } - - gboolean parsed = parse_response(response, request_id, result, error); - g_free(response); - g_mutex_unlock(&self->request_mutex); - - return parsed; -} diff --git a/desktop/src/ipc/kosmos_ipc_client.h b/desktop/src/ipc/kosmos_ipc_client.h deleted file mode 100644 index 599f538f..00000000 --- a/desktop/src/ipc/kosmos_ipc_client.h +++ /dev/null @@ -1,233 +0,0 @@ -#pragma once - -#include -#include - -#include "ipc/kosmos_ipc_protocol.h" - -G_BEGIN_DECLS - -#define KOSMOS_TYPE_IPC_CLIENT (kosmos_ipc_client_get_type()) -#define KOSMOS_IPC_ERROR (kosmos_ipc_error_quark()) - -G_DECLARE_FINAL_TYPE(KosmosIpcClient, kosmos_ipc_client, KOSMOS, IPC_CLIENT, GObject) - -typedef enum { - KOSMOS_IPC_ERROR_CONNECTION, - KOSMOS_IPC_ERROR_NOT_CONNECTED, - KOSMOS_IPC_ERROR_IO, - KOSMOS_IPC_ERROR_INVALID_RESPONSE, - KOSMOS_IPC_ERROR_SERVER, -} KosmosIpcError; - -GQuark kosmos_ipc_error_quark(void); - -char *kosmos_ipc_default_socket_path(void); -KosmosIpcClient *kosmos_ipc_client_new(const char *socket_path); -KosmosIpcClient *kosmos_ipc_client_new_from_environment(void); - -const char *kosmos_ipc_client_get_socket_path(KosmosIpcClient *self); -gboolean kosmos_ipc_client_is_connected(KosmosIpcClient *self); -gboolean kosmos_ipc_client_connect(KosmosIpcClient *self, GCancellable *cancellable, GError **error); -void kosmos_ipc_client_disconnect(KosmosIpcClient *self); - -gboolean kosmos_ipc_client_request( - KosmosIpcClient *self, - KosmosIpcDomain domain, - const char *action, - JsonObject *params, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_list_workspaces( - KosmosIpcClient *self, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_open_workspace( - KosmosIpcClient *self, - const char *path, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_activate_workspace( - KosmosIpcClient *self, - guint64 workspace_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_list_file_tree( - KosmosIpcClient *self, - guint64 workspace_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_create_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *parent_path, - const char *name, - gboolean directory, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_rename_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *name, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_delete_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_move_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *target_directory_path, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_copy_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *target_directory_path, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_activate_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_split_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_move_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_resize_pane_split( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 split_id, - double ratio, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_open_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - const char *title, - KosmosIpcTabKind kind, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_activate_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_set_tab_kind( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - KosmosIpcTabKind kind, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_close_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_reorder_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - guint target_index, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -gboolean kosmos_ipc_client_split_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - guint64 tab_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -); - -G_END_DECLS diff --git a/desktop/src/ipc/kosmos_ipc_file_tree.c b/desktop/src/ipc/kosmos_ipc_file_tree.c deleted file mode 100644 index cc94d345..00000000 --- a/desktop/src/ipc/kosmos_ipc_file_tree.c +++ /dev/null @@ -1,179 +0,0 @@ -#include "ipc/kosmos_ipc_client.h" - -gboolean kosmos_ipc_client_list_file_tree( - KosmosIpcClient *self, - guint64 workspace_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_LIST, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_create_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *parent_path, - const char *name, - gboolean directory, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(parent_path != NULL, FALSE); - g_return_val_if_fail(name != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_string_member(params, "parentPath", parent_path); - json_object_set_string_member(params, "name", name); - json_object_set_string_member(params, "kind", directory ? "directory" : "file"); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_CREATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_rename_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *name, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - g_return_val_if_fail(name != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_string_member(params, "path", path); - json_object_set_string_member(params, "name", name); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_RENAME, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_delete_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_string_member(params, "path", path); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_DELETE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_move_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *target_directory_path, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - g_return_val_if_fail(target_directory_path != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_string_member(params, "path", path); - json_object_set_string_member(params, "targetDirectoryPath", target_directory_path); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_MOVE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_copy_file_tree_entry( - KosmosIpcClient *self, - guint64 workspace_id, - const char *path, - const char *target_directory_path, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - g_return_val_if_fail(target_directory_path != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_string_member(params, "path", path); - json_object_set_string_member(params, "targetDirectoryPath", target_directory_path); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_FILE_TREE, - KOSMOS_IPC_ACTION_COPY, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} diff --git a/desktop/src/ipc/kosmos_ipc_pane.c b/desktop/src/ipc/kosmos_ipc_pane.c deleted file mode 100644 index 35d0bc2d..00000000 --- a/desktop/src/ipc/kosmos_ipc_pane.c +++ /dev/null @@ -1,117 +0,0 @@ -#include "ipc/kosmos_ipc_client.h" - -gboolean kosmos_ipc_client_activate_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_split_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_SPLIT, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_move_pane( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_MOVE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_resize_pane_split( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 split_id, - double ratio, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "splitId", (gint64)split_id); - json_object_set_double_member(params, "ratio", ratio); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_ACTION_RESIZE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.c b/desktop/src/ipc/kosmos_ipc_protocol.c deleted file mode 100644 index 57dd581c..00000000 --- a/desktop/src/ipc/kosmos_ipc_protocol.c +++ /dev/null @@ -1,48 +0,0 @@ -#include "ipc/kosmos_ipc_protocol.h" - -const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain) { - switch (domain) { - case KOSMOS_IPC_DOMAIN_WORKSPACE: - return "workspace"; - case KOSMOS_IPC_DOMAIN_PANE: - return "pane"; - case KOSMOS_IPC_DOMAIN_TAB: - return "tab"; - case KOSMOS_IPC_DOMAIN_FILE_TREE: - return "fileTree"; - } - - g_return_val_if_reached("workspace"); -} - -const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis) { - switch (axis) { - case KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL: - return "horizontal"; - case KOSMOS_IPC_SPLIT_AXIS_VERTICAL: - return "vertical"; - } - - g_return_val_if_reached("horizontal"); -} - -const char *kosmos_ipc_tab_kind_to_string(KosmosIpcTabKind kind) { - switch (kind) { - case KOSMOS_IPC_TAB_KIND_BLANK: - return "blank"; - case KOSMOS_IPC_TAB_KIND_FILE_TREE: - return "fileTree"; - case KOSMOS_IPC_TAB_KIND_EDITOR: - return "editor"; - case KOSMOS_IPC_TAB_KIND_GIT: - return "git"; - case KOSMOS_IPC_TAB_KIND_SEARCH: - return "search"; - case KOSMOS_IPC_TAB_KIND_TERMINAL: - return "terminal"; - case KOSMOS_IPC_TAB_KIND_SETTINGS: - return "settings"; - } - - g_return_val_if_reached("blank"); -} diff --git a/desktop/src/ipc/kosmos_ipc_protocol.h b/desktop/src/ipc/kosmos_ipc_protocol.h deleted file mode 100644 index eacaec23..00000000 --- a/desktop/src/ipc/kosmos_ipc_protocol.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include - -G_BEGIN_DECLS - -typedef enum { - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_DOMAIN_PANE, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_DOMAIN_FILE_TREE, -} KosmosIpcDomain; - -typedef enum { - KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, - KOSMOS_IPC_SPLIT_AXIS_VERTICAL, -} KosmosIpcSplitAxis; - -typedef enum { - KOSMOS_IPC_TAB_KIND_BLANK, - KOSMOS_IPC_TAB_KIND_FILE_TREE, - KOSMOS_IPC_TAB_KIND_EDITOR, - KOSMOS_IPC_TAB_KIND_GIT, - KOSMOS_IPC_TAB_KIND_SEARCH, - KOSMOS_IPC_TAB_KIND_TERMINAL, - KOSMOS_IPC_TAB_KIND_SETTINGS, -} KosmosIpcTabKind; - -#define KOSMOS_IPC_ACTION_LIST "list" -#define KOSMOS_IPC_ACTION_CREATE "create" -#define KOSMOS_IPC_ACTION_RENAME "rename" -#define KOSMOS_IPC_ACTION_DELETE "delete" -#define KOSMOS_IPC_ACTION_OPEN "open" -#define KOSMOS_IPC_ACTION_ACTIVATE "activate" -#define KOSMOS_IPC_ACTION_SET_KIND "setKind" -#define KOSMOS_IPC_ACTION_CLOSE "close" -#define KOSMOS_IPC_ACTION_SPLIT "split" -#define KOSMOS_IPC_ACTION_REORDER "reorder" -#define KOSMOS_IPC_ACTION_MOVE "move" -#define KOSMOS_IPC_ACTION_COPY "copy" -#define KOSMOS_IPC_ACTION_RESIZE "resize" - -const char *kosmos_ipc_domain_to_string(KosmosIpcDomain domain); -const char *kosmos_ipc_split_axis_to_string(KosmosIpcSplitAxis axis); -const char *kosmos_ipc_tab_kind_to_string(KosmosIpcTabKind kind); - -G_END_DECLS diff --git a/desktop/src/ipc/kosmos_ipc_tab.c b/desktop/src/ipc/kosmos_ipc_tab.c deleted file mode 100644 index 36f9d94e..00000000 --- a/desktop/src/ipc/kosmos_ipc_tab.c +++ /dev/null @@ -1,181 +0,0 @@ -#include "ipc/kosmos_ipc_client.h" - -gboolean kosmos_ipc_client_open_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - const char *title, - KosmosIpcTabKind kind, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_string_member(params, "title", title == NULL ? "Blank" : title); - json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_OPEN, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_activate_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_close_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_CLOSE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_set_tab_kind( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - KosmosIpcTabKind kind, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - json_object_set_string_member(params, "kind", kosmos_ipc_tab_kind_to_string(kind)); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_SET_KIND, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_reorder_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - guint target_index, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - json_object_set_int_member(params, "targetIndex", (gint64)target_index); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_REORDER, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_split_tab( - KosmosIpcClient *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - guint64 tab_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - json_object_set_int_member(params, "paneId", (gint64)pane_id); - json_object_set_int_member(params, "targetPaneId", (gint64)target_pane_id); - json_object_set_int_member(params, "tabId", (gint64)tab_id); - json_object_set_string_member(params, "axis", kosmos_ipc_split_axis_to_string(axis)); - json_object_set_boolean_member(params, "newPaneFirst", new_pane_first); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_TAB, - KOSMOS_IPC_ACTION_SPLIT, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} diff --git a/desktop/src/ipc/kosmos_ipc_workspace.c b/desktop/src/ipc/kosmos_ipc_workspace.c deleted file mode 100644 index 6f11addb..00000000 --- a/desktop/src/ipc/kosmos_ipc_workspace.c +++ /dev/null @@ -1,68 +0,0 @@ -#include "ipc/kosmos_ipc_client.h" - -gboolean kosmos_ipc_client_list_workspaces( - KosmosIpcClient *self, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - return kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_LIST, - NULL, - result, - cancellable, - error - ); -} - -gboolean kosmos_ipc_client_open_workspace( - KosmosIpcClient *self, - const char *path, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - g_return_val_if_fail(path != NULL, FALSE); - - JsonObject *params = json_object_new(); - json_object_set_string_member(params, "path", path); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_OPEN, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} - -gboolean kosmos_ipc_client_activate_workspace( - KosmosIpcClient *self, - guint64 workspace_id, - JsonNode **result, - GCancellable *cancellable, - GError **error -) { - JsonObject *params = json_object_new(); - json_object_set_int_member(params, "workspaceId", (gint64)workspace_id); - - gboolean requested = kosmos_ipc_client_request( - self, - KOSMOS_IPC_DOMAIN_WORKSPACE, - KOSMOS_IPC_ACTION_ACTIVATE, - params, - result, - cancellable, - error - ); - - json_object_unref(params); - return requested; -} diff --git a/desktop/src/lib/utils.ts b/desktop/src/lib/utils.ts new file mode 100644 index 00000000..bd0c391d --- /dev/null +++ b/desktop/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/desktop/src/main.c b/desktop/src/main.c deleted file mode 100644 index 9b402606..00000000 --- a/desktop/src/main.c +++ /dev/null @@ -1,13 +0,0 @@ -#include - -#include "app/kosmos_application.h" - -int main(int argc, char *argv[]) { - adw_init(); - - KosmosApplication *app = kosmos_application_new(); - int status = g_application_run(G_APPLICATION(app), argc, argv); - g_object_unref(app); - - return status; -} diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts new file mode 100644 index 00000000..7f6dddef --- /dev/null +++ b/desktop/src/main/main.ts @@ -0,0 +1,93 @@ +import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import fs from "node:fs"; +import path from "node:path"; + +import type { KosmosIpcDomain, KosmosIpcRequest } from "../shared/ipc"; +import { KosmosServerClient } from "./server-client"; + +const validDomains = new Set(["workspace", "pane", "tab"]); +const serverClient = new KosmosServerClient(); + +function registerIpcHandlers(): void { + ipcMain.handle("kosmos:request", (_event, request: KosmosIpcRequest) => { + validateRequest(request); + return serverClient.request(request.domain, request.action, request.params ?? {}); + }); + + ipcMain.handle("kosmos:socketPath", () => serverClient.socketPath); + + ipcMain.handle("kosmos:selectWorkspaceDirectory", async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + title: "Open Workspace", + }); + + return result.canceled ? undefined : result.filePaths[0]; + }); +} + +function createMainWindow(): void { + const runtimeDirectory = getRuntimeDirectory(); + const window = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 900, + minHeight: 600, + title: "Kosmos", + backgroundColor: "#111217", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.join(runtimeDirectory, "preload.cjs"), + sandbox: false, + }, + }); + + void window.loadFile(path.join(runtimeDirectory, "renderer", "index.html")); +} + +function getRuntimeDirectory(): string { + const packageJsonPath = path.join(app.getAppPath(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + main?: unknown; + }; + + if (typeof packageJson.main !== "string" || packageJson.main.length === 0) { + throw new Error("package.json must define the Electron main entry"); + } + + return path.dirname(path.resolve(app.getAppPath(), packageJson.main)); +} + +function validateRequest(request: KosmosIpcRequest): void { + if (!request || typeof request !== "object") { + throw new Error("IPC request must be an object"); + } + + if (!validDomains.has(request.domain)) { + throw new Error(`Unsupported IPC domain: ${String(request.domain)}`); + } + + if (typeof request.action !== "string" || request.action.length === 0) { + throw new Error("IPC request action must be a non-empty string"); + } +} + +app.whenReady().then(() => { + registerIpcHandlers(); + createMainWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + serverClient.disconnect(); + + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/desktop/src/main/server-client.ts b/desktop/src/main/server-client.ts new file mode 100644 index 00000000..5022f257 --- /dev/null +++ b/desktop/src/main/server-client.ts @@ -0,0 +1,174 @@ +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import type { KosmosIpcDomain, KosmosIpcParams, KosmosServerResponse } from "../shared/ipc"; + +type PendingRequest = { + resolve(value: unknown): void; + reject(error: Error): void; +}; + +export class KosmosIpcRequestError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(`${code}: ${message}`); + this.name = "KosmosIpcRequestError"; + } +} + +export class KosmosServerClient { + private buffer = ""; + private connecting: Promise | undefined; + private nextRequestId = 1; + private socket: net.Socket | undefined; + private readonly pending = new Map(); + + constructor(readonly socketPath = defaultSocketPath()) {} + + async request( + domain: KosmosIpcDomain, + action: string, + params: KosmosIpcParams = {}, + ): Promise { + await this.connect(); + + const socket = this.socket; + if (!socket || socket.destroyed) { + throw new Error(`IPC client is not connected to ${this.socketPath}`); + } + + const id = this.nextRequestId++; + const payload = JSON.stringify({ type: "request", id, domain, action, params }); + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + + socket.write(`${payload}\n`, "utf8", (error) => { + if (!error) { + return; + } + + this.pending.delete(id); + reject(error); + this.disconnect(); + }); + }); + } + + disconnect(): void { + this.socket?.destroy(); + this.socket = undefined; + this.rejectAll(new Error("IPC connection closed")); + } + + private async connect(): Promise { + if (this.socket && !this.socket.destroyed) { + return; + } + + if (this.connecting) { + return this.connecting; + } + + this.connecting = new Promise((resolve, reject) => { + const socket = net.createConnection(this.socketPath); + socket.setEncoding("utf8"); + + const onConnect = () => { + socket.off("error", onConnectError); + this.socket = socket; + this.buffer = ""; + + socket.on("data", (chunk) => this.handleData(chunk)); + socket.on("error", (error) => this.rejectAll(error)); + socket.on("close", () => this.handleClose()); + + resolve(); + }; + + const onConnectError = (error: Error) => { + socket.off("connect", onConnect); + reject(error); + }; + + socket.once("connect", onConnect); + socket.once("error", onConnectError); + }).finally(() => { + this.connecting = undefined; + }); + + return this.connecting; + } + + private handleData(chunk: string | Buffer): void { + this.buffer += chunk.toString(); + + const frames = this.buffer.split("\n"); + this.buffer = frames.pop() ?? ""; + + for (const frame of frames) { + const trimmed = frame.trim(); + if (trimmed.length > 0) { + this.handleFrame(trimmed); + } + } + } + + private handleFrame(frame: string): void { + const response = parseResponse(frame); + const pending = this.pending.get(response.id); + + if (!pending) { + return; + } + + this.pending.delete(response.id); + + if (!response.ok) { + const error = response.error ?? { + code: "ipc.server_error", + message: "server returned an IPC error", + }; + pending.reject(new KosmosIpcRequestError(error.code, error.message)); + return; + } + + pending.resolve(response.result); + } + + private handleClose(): void { + this.socket = undefined; + this.rejectAll(new Error("IPC server closed the connection")); + } + + private rejectAll(error: Error): void { + for (const request of this.pending.values()) { + request.reject(error); + } + + this.pending.clear(); + } +} + +export function defaultSocketPath(): string { + const socketPath = process.env.KOSMOS_SOCKET; + if (socketPath && socketPath.length > 0) { + return socketPath; + } + + const runtimeDir = process.env.XDG_RUNTIME_DIR || os.tmpdir(); + return path.join(runtimeDir, "kosmos", "server.sock"); +} + +function parseResponse(frame: string): KosmosServerResponse { + const response = JSON.parse(frame) as Partial; + + if (response.type !== "response" || typeof response.id !== "number") { + throw new Error("Invalid IPC response from server"); + } + + return response as KosmosServerResponse; +} diff --git a/desktop/src/preload/preload.ts b/desktop/src/preload/preload.ts new file mode 100644 index 00000000..5b87aada --- /dev/null +++ b/desktop/src/preload/preload.ts @@ -0,0 +1,17 @@ +import { contextBridge, ipcRenderer } from "electron"; + +import type { KosmosApi, KosmosIpcRequest } from "../shared/ipc"; + +const kosmos: KosmosApi = { + request(request: KosmosIpcRequest): Promise { + return ipcRenderer.invoke("kosmos:request", request) as Promise; + }, + getSocketPath(): Promise { + return ipcRenderer.invoke("kosmos:socketPath") as Promise; + }, + selectWorkspaceDirectory(): Promise { + return ipcRenderer.invoke("kosmos:selectWorkspaceDirectory") as Promise; + }, +}; + +contextBridge.exposeInMainWorld("kosmos", kosmos); diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx new file mode 100644 index 00000000..a61b0fab --- /dev/null +++ b/desktop/src/renderer/App.tsx @@ -0,0 +1,12 @@ +import { Button } from "@/components/ui/button"; + +export function App() { + return ( +
+
+

Hello world

+ +
+
+ ); +} diff --git a/desktop/src/renderer/global.d.ts b/desktop/src/renderer/global.d.ts new file mode 100644 index 00000000..3824a496 --- /dev/null +++ b/desktop/src/renderer/global.d.ts @@ -0,0 +1,9 @@ +import type { KosmosApi } from "../shared/ipc"; + +declare global { + interface Window { + kosmos: KosmosApi; + } +} + +export {}; diff --git a/desktop/src/renderer/index.css b/desktop/src/renderer/index.css new file mode 100644 index 00000000..19a81c89 --- /dev/null +++ b/desktop/src/renderer/index.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/geist"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-heading: var(--font-sans); + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html new file mode 100644 index 00000000..134659bd --- /dev/null +++ b/desktop/src/renderer/index.html @@ -0,0 +1,12 @@ + + + + + + Kosmos + + +
+ + + diff --git a/desktop/src/renderer/ipc/index.ts b/desktop/src/renderer/ipc/index.ts new file mode 100644 index 00000000..2a475759 --- /dev/null +++ b/desktop/src/renderer/ipc/index.ts @@ -0,0 +1,4 @@ +export * from "./pane"; +export * from "./tab"; +export * from "./transport"; +export * from "./workspace"; diff --git a/desktop/src/renderer/ipc/pane.ts b/desktop/src/renderer/ipc/pane.ts new file mode 100644 index 00000000..e050eee1 --- /dev/null +++ b/desktop/src/renderer/ipc/pane.ts @@ -0,0 +1,29 @@ +import type { + ActivatePaneParams, + MovePaneParams, + ResizeSplitParams, + SplitPaneParams, + WorkspaceListSnapshot, +} from "@/shared/ipc"; + +import { requestServer } from "./transport"; + +const DOMAIN = "pane"; + +export function splitPane(params: SplitPaneParams): Promise { + return requestServer(DOMAIN, "split", params); +} + +export function activatePane(params: ActivatePaneParams): Promise { + return requestServer(DOMAIN, "activate", params); +} + +export function movePane(params: MovePaneParams): Promise { + return requestServer(DOMAIN, "move", params); +} + +export function resizeSplit(params: ResizeSplitParams): Promise { + return requestServer(DOMAIN, "resize", params); +} + +export type { PaneNodeSnapshot, PaneSnapshot, SplitAxis } from "@/shared/ipc"; diff --git a/desktop/src/renderer/ipc/tab.ts b/desktop/src/renderer/ipc/tab.ts new file mode 100644 index 00000000..39773a3f --- /dev/null +++ b/desktop/src/renderer/ipc/tab.ts @@ -0,0 +1,39 @@ +import type { + ActivateTabParams, + CloseTabParams, + OpenTabParams, + ReorderTabParams, + SetTabKindParams, + SplitTabParams, + WorkspaceListSnapshot, +} from "@/shared/ipc"; + +import { requestServer } from "./transport"; + +const DOMAIN = "tab"; + +export function openTab(params: OpenTabParams = {}): Promise { + return requestServer(DOMAIN, "open", params); +} + +export function activateTab(params: ActivateTabParams): Promise { + return requestServer(DOMAIN, "activate", params); +} + +export function setTabKind(params: SetTabKindParams): Promise { + return requestServer(DOMAIN, "setKind", params); +} + +export function closeTab(params: CloseTabParams): Promise { + return requestServer(DOMAIN, "close", params); +} + +export function reorderTab(params: ReorderTabParams): Promise { + return requestServer(DOMAIN, "reorder", params); +} + +export function splitTab(params: SplitTabParams): Promise { + return requestServer(DOMAIN, "split", params); +} + +export type { TabKind, TabSnapshot } from "@/shared/ipc"; diff --git a/desktop/src/renderer/ipc/transport.ts b/desktop/src/renderer/ipc/transport.ts new file mode 100644 index 00000000..f5fe0f71 --- /dev/null +++ b/desktop/src/renderer/ipc/transport.ts @@ -0,0 +1,25 @@ +import type { KosmosIpcDomain, KosmosIpcParams } from "@/shared/ipc"; + +export function requestServer( + domain: KosmosIpcDomain, + action: string, + params?: KosmosIpcParams, +): Promise { + return kosmosApi().request({ domain, action, params }); +} + +export function getSocketPath(): Promise { + return kosmosApi().getSocketPath(); +} + +export function selectWorkspaceDirectory(): Promise { + return kosmosApi().selectWorkspaceDirectory(); +} + +function kosmosApi() { + if (!window.kosmos) { + throw new Error("Electron preload did not expose the Kosmos IPC API."); + } + + return window.kosmos; +} diff --git a/desktop/src/renderer/ipc/workspace.ts b/desktop/src/renderer/ipc/workspace.ts new file mode 100644 index 00000000..418f443a --- /dev/null +++ b/desktop/src/renderer/ipc/workspace.ts @@ -0,0 +1,29 @@ +import type { + ActivateWorkspaceParams, + CloseWorkspaceParams, + OpenWorkspaceParams, + WorkspaceId, + WorkspaceListSnapshot, +} from "@/shared/ipc"; + +import { requestServer } from "./transport"; + +const DOMAIN = "workspace"; + +export function listWorkspaces(): Promise { + return requestServer(DOMAIN, "list"); +} + +export function openWorkspace(path: string): Promise { + return requestServer(DOMAIN, "open", { path } satisfies OpenWorkspaceParams); +} + +export function activateWorkspace(workspaceId: WorkspaceId): Promise { + return requestServer(DOMAIN, "activate", { workspaceId } satisfies ActivateWorkspaceParams); +} + +export function closeWorkspace(workspaceId?: WorkspaceId | null): Promise { + return requestServer(DOMAIN, "close", { workspaceId } satisfies CloseWorkspaceParams); +} + +export type { WorkspaceListSnapshot, WorkspaceSnapshot } from "@/shared/ipc"; diff --git a/desktop/src/renderer/main.tsx b/desktop/src/renderer/main.tsx new file mode 100644 index 00000000..f35292ec --- /dev/null +++ b/desktop/src/renderer/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./index.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing root element"); +} + +createRoot(root).render( + + + , +); diff --git a/desktop/src/shared/ipc/envelope.ts b/desktop/src/shared/ipc/envelope.ts new file mode 100644 index 00000000..312763b6 --- /dev/null +++ b/desktop/src/shared/ipc/envelope.ts @@ -0,0 +1,28 @@ +export type KosmosIpcDomain = "workspace" | "pane" | "tab"; + +export type KosmosIpcParams = Record; + +export type KosmosIpcRequest = { + domain: KosmosIpcDomain; + action: string; + params?: KosmosIpcParams; +}; + +export type KosmosIpcError = { + code: string; + message: string; +}; + +export type KosmosServerResponse = { + type: "response"; + id: number; + ok: boolean; + result?: unknown; + error?: KosmosIpcError; +}; + +export type KosmosApi = { + request(request: KosmosIpcRequest): Promise; + getSocketPath(): Promise; + selectWorkspaceDirectory(): Promise; +}; diff --git a/desktop/src/shared/ipc/ids.ts b/desktop/src/shared/ipc/ids.ts new file mode 100644 index 00000000..3195d7bd --- /dev/null +++ b/desktop/src/shared/ipc/ids.ts @@ -0,0 +1,4 @@ +export type WorkspaceId = number; +export type PaneId = number; +export type SplitPaneId = number; +export type TabId = number; diff --git a/desktop/src/shared/ipc/index.ts b/desktop/src/shared/ipc/index.ts new file mode 100644 index 00000000..e08f343b --- /dev/null +++ b/desktop/src/shared/ipc/index.ts @@ -0,0 +1,5 @@ +export type * from "./envelope"; +export type * from "./ids"; +export type * from "./pane"; +export type * from "./tab"; +export type * from "./workspace"; diff --git a/desktop/src/shared/ipc/pane.ts b/desktop/src/shared/ipc/pane.ts new file mode 100644 index 00000000..bff380ff --- /dev/null +++ b/desktop/src/shared/ipc/pane.ts @@ -0,0 +1,50 @@ +import type { PaneId, SplitPaneId, WorkspaceId } from "./ids"; +import type { TabSnapshot } from "./tab"; + +export type SplitAxis = "horizontal" | "vertical"; + +export type SplitPaneParams = { + workspaceId?: WorkspaceId | null; + paneId?: PaneId | null; + axis: SplitAxis; + newPaneFirst?: boolean; +}; + +export type ActivatePaneParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; +}; + +export type MovePaneParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + targetPaneId: PaneId; + axis: SplitAxis; + newPaneFirst?: boolean; +}; + +export type ResizeSplitParams = { + workspaceId?: WorkspaceId | null; + splitId: SplitPaneId; + ratio: number; +}; + +export type PaneNodeSnapshot = + | { + type: "leaf"; + pane: PaneSnapshot; + } + | { + type: "split"; + id: SplitPaneId; + axis: SplitAxis; + ratio: number; + first: PaneNodeSnapshot; + second: PaneNodeSnapshot; + }; + +export type PaneSnapshot = { + id: PaneId; + activeTabId: number; + tabs: TabSnapshot[]; +}; diff --git a/desktop/src/shared/ipc/tab.ts b/desktop/src/shared/ipc/tab.ts new file mode 100644 index 00000000..8a498298 --- /dev/null +++ b/desktop/src/shared/ipc/tab.ts @@ -0,0 +1,52 @@ +import type { PaneId, TabId, WorkspaceId } from "./ids"; +import type { SplitAxis } from "./pane"; + +export type TabKind = "blank" | "fileTree" | "editor" | "git" | "search" | "terminal" | "settings"; + +export type OpenTabParams = { + workspaceId?: WorkspaceId | null; + paneId?: PaneId | null; + title?: string; + kind?: TabKind; +}; + +export type ActivateTabParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + tabId: TabId; +}; + +export type SetTabKindParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + tabId: TabId; + kind: TabKind; +}; + +export type CloseTabParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + tabId: TabId; +}; + +export type ReorderTabParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + tabId: TabId; + targetIndex: number; +}; + +export type SplitTabParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + targetPaneId?: PaneId | null; + tabId: TabId; + axis: SplitAxis; + newPaneFirst?: boolean; +}; + +export type TabSnapshot = { + id: TabId; + title: string; + kind: TabKind; +}; diff --git a/desktop/src/shared/ipc/workspace.ts b/desktop/src/shared/ipc/workspace.ts new file mode 100644 index 00000000..5cd82584 --- /dev/null +++ b/desktop/src/shared/ipc/workspace.ts @@ -0,0 +1,27 @@ +import type { PaneId, WorkspaceId } from "./ids"; +import type { PaneNodeSnapshot } from "./pane"; + +export type OpenWorkspaceParams = { + path: string; +}; + +export type ActivateWorkspaceParams = { + workspaceId: WorkspaceId; +}; + +export type CloseWorkspaceParams = { + workspaceId?: WorkspaceId | null; +}; + +export type WorkspaceListSnapshot = { + activeWorkspaceId: WorkspaceId | null; + workspaces: WorkspaceSnapshot[]; +}; + +export type WorkspaceSnapshot = { + id: WorkspaceId; + name: string; + directory: string; + activePaneId: PaneId; + root: PaneNodeSnapshot; +}; diff --git a/desktop/src/tabs/kosmos_blank_tab.c b/desktop/src/tabs/kosmos_blank_tab.c deleted file mode 100644 index 06b31f7e..00000000 --- a/desktop/src/tabs/kosmos_blank_tab.c +++ /dev/null @@ -1,106 +0,0 @@ -#include "tabs/kosmos_blank_tab.h" - -#include - -typedef struct { - KosmosIpcTabKind kind; - KosmosBlankTabKindSelectedFunc kind_selected; - gpointer user_data; -} TabKindSelection; - -typedef struct { - const char *label; - KosmosIpcTabKind kind; -} TabKindButton; - -static void tab_kind_selection_free(TabKindSelection *selection) { - g_free(selection); -} - -static void tab_kind_selection_closure_free(gpointer data, GClosure *closure) { - (void)closure; - - tab_kind_selection_free(data); -} - -static void select_tab_kind(GtkButton *button, gpointer user_data) { - (void)button; - - TabKindSelection *selection = user_data; - if (selection->kind_selected != NULL) { - selection->kind_selected(selection->kind, selection->user_data); - } -} - -static GtkWidget *create_tab_kind_button( - const TabKindButton *tab_kind, - KosmosBlankTabKindSelectedFunc kind_selected, - gpointer user_data -) { - GtkWidget *button = gtk_button_new_with_label(tab_kind->label); - TabKindSelection *selection = g_new(TabKindSelection, 1); - - selection->kind = tab_kind->kind; - selection->kind_selected = kind_selected; - selection->user_data = user_data; - - g_signal_connect_data( - button, - "clicked", - G_CALLBACK(select_tab_kind), - selection, - tab_kind_selection_closure_free, - 0 - ); - - return button; -} - -GtkWidget *kosmos_blank_tab_create(KosmosBlankTabKindSelectedFunc kind_selected, gpointer user_data) { - static const TabKindButton tab_kinds[] = { - {"File Tree", KOSMOS_IPC_TAB_KIND_FILE_TREE}, - {"Editor", KOSMOS_IPC_TAB_KIND_EDITOR}, - {"Git", KOSMOS_IPC_TAB_KIND_GIT}, - {"Search", KOSMOS_IPC_TAB_KIND_SEARCH}, - {"Terminal", KOSMOS_IPC_TAB_KIND_TERMINAL}, - {"Settings", KOSMOS_IPC_TAB_KIND_SETTINGS}, - }; - - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *top_spacer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); - GtkWidget *buttons = adw_wrap_box_new(); - GtkWidget *bottom_spacer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *label = gtk_label_new("Choose a tab type"); - - gtk_widget_set_hexpand(content, TRUE); - gtk_widget_set_vexpand(content, TRUE); - gtk_widget_set_margin_top(content, 24); - gtk_widget_set_margin_bottom(content, 24); - gtk_widget_set_margin_start(content, 24); - gtk_widget_set_margin_end(content, 24); - gtk_widget_set_vexpand(top_spacer, TRUE); - gtk_widget_set_vexpand(bottom_spacer, TRUE); - gtk_widget_set_halign(list, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(list, TRUE); - gtk_widget_set_halign(label, GTK_ALIGN_CENTER); - gtk_widget_set_halign(buttons, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(buttons, TRUE); - adw_wrap_box_set_child_spacing(ADW_WRAP_BOX(buttons), 8); - adw_wrap_box_set_line_spacing(ADW_WRAP_BOX(buttons), 8); - adw_wrap_box_set_align(ADW_WRAP_BOX(buttons), 0.5f); - adw_wrap_box_set_wrap_policy(ADW_WRAP_BOX(buttons), ADW_WRAP_MINIMUM); - gtk_widget_add_css_class(label, "heading"); - - gtk_box_append(GTK_BOX(list), label); - for (gsize index = 0; index < G_N_ELEMENTS(tab_kinds); index++) { - adw_wrap_box_append(ADW_WRAP_BOX(buttons), create_tab_kind_button(&tab_kinds[index], kind_selected, user_data)); - } - gtk_box_append(GTK_BOX(list), buttons); - - gtk_box_append(GTK_BOX(content), top_spacer); - gtk_box_append(GTK_BOX(content), list); - gtk_box_append(GTK_BOX(content), bottom_spacer); - - return content; -} diff --git a/desktop/src/tabs/kosmos_blank_tab.h b/desktop/src/tabs/kosmos_blank_tab.h deleted file mode 100644 index d0b0ac66..00000000 --- a/desktop/src/tabs/kosmos_blank_tab.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -#include "ipc/kosmos_ipc_protocol.h" - -G_BEGIN_DECLS - -typedef void (*KosmosBlankTabKindSelectedFunc)(KosmosIpcTabKind kind, gpointer user_data); - -GtkWidget *kosmos_blank_tab_create(KosmosBlankTabKindSelectedFunc kind_selected, gpointer user_data); - -G_END_DECLS diff --git a/desktop/src/tabs/kosmos_file_tree_tab.c b/desktop/src/tabs/kosmos_file_tree_tab.c deleted file mode 100644 index c80b32c2..00000000 --- a/desktop/src/tabs/kosmos_file_tree_tab.c +++ /dev/null @@ -1,2153 +0,0 @@ -#include "tabs/kosmos_file_tree_tab.h" - -#include - -#define KOSMOS_TYPE_FILE_TREE_ITEM (kosmos_file_tree_item_get_type()) -#define KOSMOS_FILE_TREE_ITEM(object) (G_TYPE_CHECK_INSTANCE_CAST((object), KOSMOS_TYPE_FILE_TREE_ITEM, KosmosFileTreeItem)) -#define FILE_TREE_MONITOR_MAX_DIRECTORIES 256 -#define FILE_TREE_MONITOR_MAX_DEPTH 4 - -typedef struct _KosmosFileTreeItem KosmosFileTreeItem; -typedef struct _KosmosFileTreeItemClass KosmosFileTreeItemClass; - -typedef struct { - char *path; - char *kind; - char *name; -} FileTreeEntryRef; - -typedef struct { - KosmosIpcClient *ipc_client; - guint64 workspace_id; - GtkWidget *root; - GtkWidget *notice_label; - GtkWidget *scrolled; - GtkWidget *list; - GtkTreeListModel *tree_model; - GHashTable *expanded_paths; - GPtrArray *monitors; - guint monitor_refresh_source_id; - guint busy_count; - char *root_path; - char *root_name; - char *focused_path; - char *pending_focus_path; - char *editing_path; - GPtrArray *clipboard_entries; - gboolean clipboard_cut; -} FileTreeContext; - -typedef enum { - FILE_TREE_ASYNC_LIST, - FILE_TREE_ASYNC_DELETE, - FILE_TREE_ASYNC_MOVE, - FILE_TREE_ASYNC_COPY, -} FileTreeAsyncOperation; - -typedef struct { - GtkWidget *root; - KosmosIpcClient *ipc_client; - guint64 workspace_id; - FileTreeAsyncOperation operation; - char *path; - char *target_directory_path; - GPtrArray *entries; - char *pending_focus_path; - char *error_prefix; - gboolean clear_clipboard_on_success; - gboolean show_progress; -} FileTreeAsyncRequest; - -typedef enum { - FILE_TREE_DIALOG_CREATE_FILE, - FILE_TREE_DIALOG_CREATE_DIRECTORY, -} FileTreeDialogOperation; - -typedef struct { - FileTreeContext *context; - FileTreeDialogOperation operation; - char *path; - AdwDialog *dialog; - GtkWidget *entry; - GtkWidget *error_label; -} FileTreeNameDialog; - -typedef struct { - FileTreeContext *context; - GtkWidget *root; - GPtrArray *entries; -} FileTreeDeleteDialog; - -typedef enum { - FILE_TREE_MENU_NEW_FILE, - FILE_TREE_MENU_NEW_FOLDER, - FILE_TREE_MENU_RENAME, - FILE_TREE_MENU_DELETE, - FILE_TREE_MENU_REFRESH, - FILE_TREE_MENU_CUT, - FILE_TREE_MENU_COPY, - FILE_TREE_MENU_PASTE, - FILE_TREE_MENU_REVEAL, -} FileTreeMenuOperation; - -typedef struct { - FileTreeContext *context; - FileTreeMenuOperation operation; - char *path; - char *kind; - char *name; - GPtrArray *entries; - GtkWidget *popover; -} FileTreeMenuAction; - -struct _KosmosFileTreeItem { - GObject parent_instance; - char *name; - char *path; - char *kind; - GListStore *children; -}; - -struct _KosmosFileTreeItemClass { - GObjectClass parent_class; -}; - -GType kosmos_file_tree_item_get_type(void); -G_DEFINE_FINAL_TYPE(KosmosFileTreeItem, kosmos_file_tree_item, G_TYPE_OBJECT) - -static void kosmos_file_tree_item_finalize(GObject *object) { - KosmosFileTreeItem *self = KOSMOS_FILE_TREE_ITEM(object); - - g_clear_pointer(&self->name, g_free); - g_clear_pointer(&self->path, g_free); - g_clear_pointer(&self->kind, g_free); - g_clear_object(&self->children); - - G_OBJECT_CLASS(kosmos_file_tree_item_parent_class)->finalize(object); -} - -static void kosmos_file_tree_item_class_init(KosmosFileTreeItemClass *klass) { - GObjectClass *object_class = G_OBJECT_CLASS(klass); - object_class->finalize = kosmos_file_tree_item_finalize; -} - -static void kosmos_file_tree_item_init(KosmosFileTreeItem *self) { - self->children = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); -} - -static FileTreeEntryRef *file_tree_entry_ref_new(const char *path, const char *kind, const char *name) { - FileTreeEntryRef *entry = g_new0(FileTreeEntryRef, 1); - - entry->path = g_strdup(path); - entry->kind = g_strdup(kind); - entry->name = g_strdup(name); - - return entry; -} - -static FileTreeEntryRef *file_tree_entry_ref_new_from_item(KosmosFileTreeItem *item) { - return file_tree_entry_ref_new(item->path, item->kind, item->name); -} - -static void file_tree_entry_ref_free(FileTreeEntryRef *entry) { - g_clear_pointer(&entry->path, g_free); - g_clear_pointer(&entry->kind, g_free); - g_clear_pointer(&entry->name, g_free); - g_free(entry); -} - -static void file_tree_context_free(FileTreeContext *context) { - if (context->monitor_refresh_source_id != 0) { - g_source_remove(context->monitor_refresh_source_id); - } - - g_clear_object(&context->ipc_client); - g_clear_object(&context->tree_model); - g_clear_pointer(&context->expanded_paths, g_hash_table_unref); - g_clear_pointer(&context->monitors, g_ptr_array_unref); - g_clear_pointer(&context->root_path, g_free); - g_clear_pointer(&context->root_name, g_free); - g_clear_pointer(&context->focused_path, g_free); - g_clear_pointer(&context->pending_focus_path, g_free); - g_clear_pointer(&context->editing_path, g_free); - g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); - g_free(context); -} - -static void file_tree_async_request_free(FileTreeAsyncRequest *request) { - g_clear_object(&request->root); - g_clear_object(&request->ipc_client); - g_clear_pointer(&request->path, g_free); - g_clear_pointer(&request->target_directory_path, g_free); - g_clear_pointer(&request->entries, g_ptr_array_unref); - g_clear_pointer(&request->pending_focus_path, g_free); - g_clear_pointer(&request->error_prefix, g_free); - g_free(request); -} - -static void file_tree_name_dialog_free(FileTreeNameDialog *request) { - g_clear_pointer(&request->path, g_free); - g_free(request); -} - -static void file_tree_delete_dialog_free(FileTreeDeleteDialog *request) { - g_clear_object(&request->root); - g_clear_pointer(&request->entries, g_ptr_array_unref); - g_free(request); -} - -static void file_tree_menu_action_free(FileTreeMenuAction *action) { - g_clear_pointer(&action->path, g_free); - g_clear_pointer(&action->kind, g_free); - g_clear_pointer(&action->name, g_free); - g_clear_pointer(&action->entries, g_ptr_array_unref); - g_clear_object(&action->popover); - g_free(action); -} - -static KosmosFileTreeItem *kosmos_file_tree_item_new(const char *name, const char *path, const char *kind) { - KosmosFileTreeItem *item = g_object_new(KOSMOS_TYPE_FILE_TREE_ITEM, NULL); - - item->name = g_strdup(name == NULL || name[0] == '\0' ? "Untitled" : name); - item->path = g_strdup(path == NULL ? "" : path); - item->kind = g_strdup(kind == NULL || kind[0] == '\0' ? "other" : kind); - - return item; -} - -static KosmosFileTreeItem *kosmos_file_tree_item_new_message(const char *message) { - return kosmos_file_tree_item_new(message, "", "message"); -} - -static gboolean kosmos_file_tree_item_is_directory(KosmosFileTreeItem *item) { - return g_strcmp0(item->kind, "directory") == 0; -} - -static gboolean kosmos_file_tree_item_is_message(KosmosFileTreeItem *item) { - return g_strcmp0(item->kind, "message") == 0; -} - -static guint kosmos_file_tree_item_child_count(KosmosFileTreeItem *item) { - return g_list_model_get_n_items(G_LIST_MODEL(item->children)); -} - -static gboolean get_string_member(JsonObject *object, const char *name, const char **value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { - return FALSE; - } - - *value = json_node_get_string(node); - return TRUE; -} - -static gboolean get_bool_member(JsonObject *object, const char *name, gboolean *value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_BOOLEAN) { - return FALSE; - } - - *value = json_node_get_boolean(node); - return TRUE; -} - -static JsonObject *get_object_member(JsonObject *object, const char *name) { - JsonNode *node = json_object_get_member(object, name); - - if (node == NULL || !JSON_NODE_HOLDS_OBJECT(node)) { - return NULL; - } - - return json_node_get_object(node); -} - -static JsonArray *get_array_member(JsonObject *object, const char *name) { - JsonNode *node = json_object_get_member(object, name); - - if (node == NULL || !JSON_NODE_HOLDS_ARRAY(node)) { - return NULL; - } - - return json_node_get_array(node); -} - -static GtkWidget *create_status_label(const char *text, const char *css_class) { - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *label = gtk_label_new(text); - - gtk_label_set_wrap(GTK_LABEL(label), TRUE); - gtk_label_set_xalign(GTK_LABEL(label), 0.5f); - gtk_widget_set_halign(label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_vexpand(label, TRUE); - gtk_widget_set_hexpand(content, TRUE); - gtk_widget_set_vexpand(content, TRUE); - - if (css_class != NULL) { - gtk_widget_add_css_class(label, css_class); - } - - gtk_box_append(GTK_BOX(content), label); - return content; -} - -static GtkWidget *create_file_tree_list(FileTreeContext *context, JsonObject *root); -static gboolean file_tree_item_is_root(FileTreeContext *context, KosmosFileTreeItem *item); -static gboolean path_is_equal_or_descendant(const char *path, const char *parent); -static void file_tree_context_apply_tree(FileTreeContext *context, JsonNode *tree); -static void apply_file_tree_operation_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix); -static void file_tree_context_refresh(FileTreeContext *context); -static void file_tree_context_refresh_with_progress(FileTreeContext *context, gboolean show_progress); -static GdkContentProvider *file_tree_drag_prepare(GtkDragSource *source, double x, double y, gpointer user_data); -static GdkDragAction file_tree_drop_enter(GtkDropTarget *target, double x, double y, gpointer user_data); -static GdkDragAction file_tree_drop_motion(GtkDropTarget *target, double x, double y, gpointer user_data); -static void file_tree_drop_leave(GtkDropTarget *target, gpointer user_data); -static gboolean file_tree_drop(GtkDropTarget *target, const GValue *value, double x, double y, gpointer user_data); -static void inline_rename_commit(GtkWidget *entry); -static void inline_rename_cancel(GtkWidget *entry); -static gboolean inline_rename_key_pressed(GtkEventControllerKey *controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data); -static void inline_rename_focus_leave(GtkEventControllerFocus *controller, gpointer user_data); - -static void file_tree_install_css(GtkWidget *widget) { - static gboolean installed = FALSE; - - if (installed) { - return; - } - - GtkCssProvider *provider = gtk_css_provider_new(); - GdkDisplay *display = gtk_widget_get_display(widget); - if (display == NULL) { - display = gdk_display_get_default(); - } - - if (display == NULL) { - g_object_unref(provider); - return; - } - - gtk_css_provider_load_from_string( - provider, - ".file-tree-drop-target {" - " background: rgba(46, 160, 67, 0.24);" - "}" - ); - gtk_style_context_add_provider_for_display( - display, - GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION - ); - g_object_unref(provider); - installed = TRUE; -} - -static void file_tree_context_set_focused_path(FileTreeContext *context, const char *path) { - if (g_strcmp0(context->focused_path, path) == 0) { - return; - } - - g_free(context->focused_path); - context->focused_path = g_strdup(path); -} - -static void file_tree_context_set_pending_focus_path(FileTreeContext *context, const char *path) { - g_free(context->pending_focus_path); - context->pending_focus_path = g_strdup(path); -} - -static void file_tree_context_set_editing_path(FileTreeContext *context, const char *path) { - g_free(context->editing_path); - context->editing_path = g_strdup(path); -} - -static void file_tree_context_clear_editing_path(FileTreeContext *context) { - g_clear_pointer(&context->editing_path, g_free); -} - -static char *file_tree_path_child(const char *parent, const char *name) { - return g_build_filename(parent, name, NULL); -} - -static char *file_tree_numbered_copy_name(const char *name, guint index) { - const char *dot = strrchr(name, '.'); - - if (dot != NULL && dot > name && dot[1] != '\0') { - return g_strdup_printf("%.*s (%u)%s", (int)(dot - name), name, index, dot); - } - - return g_strdup_printf("%s (%u)", name, index); -} - -static char *file_tree_available_copy_path(const char *target_directory, const char *name) { - char *target = file_tree_path_child(target_directory, name); - - if (!g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { - return target; - } - - g_free(target); - - for (guint index = 1;; index++) { - char *numbered_name = file_tree_numbered_copy_name(name, index); - target = file_tree_path_child(target_directory, numbered_name); - g_free(numbered_name); - - if (!g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { - return target; - } - - g_free(target); - } -} - -static gboolean file_tree_name_is_valid(const char *name) { - return name != NULL && name[0] != '\0' && g_strcmp0(name, ".") != 0 && g_strcmp0(name, "..") != 0 && strchr(name, G_DIR_SEPARATOR) == NULL && strchr(name, '/') == NULL; -} - -static char *file_tree_path_parent(const char *path) { - return g_path_get_dirname(path); -} - -static char *file_tree_paste_target_directory(const char *path, const char *kind) { - return g_strcmp0(kind, "directory") == 0 ? g_strdup(path) : file_tree_path_parent(path); -} - -static GPtrArray *file_tree_entry_ref_array_new(void) { - return g_ptr_array_new_with_free_func((GDestroyNotify)file_tree_entry_ref_free); -} - -static GPtrArray *file_tree_entry_ref_array_copy(GPtrArray *entries) { - GPtrArray *copy = file_tree_entry_ref_array_new(); - - for (guint index = 0; index < entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(entries, index); - g_ptr_array_add(copy, file_tree_entry_ref_new(entry->path, entry->kind, entry->name)); - } - - return copy; -} - -static void file_tree_context_store_clipboard(FileTreeContext *context, GPtrArray *entries, gboolean cut) { - g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); - context->clipboard_entries = file_tree_entry_ref_array_new(); - - for (guint index = 0; index < entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(entries, index); - g_ptr_array_add(context->clipboard_entries, file_tree_entry_ref_new(entry->path, entry->kind, entry->name)); - } - - context->clipboard_cut = cut; -} - -static void file_tree_context_clear_clipboard(FileTreeContext *context) { - g_clear_pointer(&context->clipboard_entries, g_ptr_array_unref); - context->clipboard_cut = FALSE; -} - -static gboolean file_tree_context_has_clipboard(FileTreeContext *context) { - if (context->clipboard_entries == NULL || context->clipboard_entries->len == 0) { - return FALSE; - } - - for (guint index = 0; index < context->clipboard_entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(context->clipboard_entries, index); - if (!g_file_test(entry->path, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK)) { - file_tree_context_clear_clipboard(context); - return FALSE; - } - } - - return TRUE; -} - -static gboolean file_tree_context_clipboard_intersects_entries(FileTreeContext *context, GPtrArray *entries) { - if (context->clipboard_entries == NULL) { - return FALSE; - } - - for (guint clipboard_index = 0; clipboard_index < context->clipboard_entries->len; clipboard_index++) { - FileTreeEntryRef *clipboard_entry = g_ptr_array_index(context->clipboard_entries, clipboard_index); - for (guint entry_index = 0; entry_index < entries->len; entry_index++) { - FileTreeEntryRef *entry = g_ptr_array_index(entries, entry_index); - if (path_is_equal_or_descendant(clipboard_entry->path, entry->path)) { - return TRUE; - } - } - } - - return FALSE; -} - -static const char *file_tree_context_clipboard_conflict_name(FileTreeContext *context, const char *target_directory) { - if (context->clipboard_entries == NULL) { - return NULL; - } - - for (guint index = 0; index < context->clipboard_entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(context->clipboard_entries, index); - char *target = file_tree_path_child(target_directory, entry->name); - gboolean conflict = g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK); - g_free(target); - - if (conflict) { - return entry->name; - } - } - - return NULL; -} - -static void file_tree_context_set_notice(FileTreeContext *context, const char *message, const char *css_class) { - if (context->notice_label == NULL) { - return; - } - - gtk_label_set_text(GTK_LABEL(context->notice_label), message == NULL ? "" : message); - gtk_widget_set_visible(context->notice_label, message != NULL && message[0] != '\0'); - gtk_widget_remove_css_class(context->notice_label, "error"); - gtk_widget_remove_css_class(context->notice_label, "dim-label"); - - if (css_class != NULL) { - gtk_widget_add_css_class(context->notice_label, css_class); - } -} - -static void file_tree_context_begin_operation(FileTreeContext *context, const char *message) { - context->busy_count++; - file_tree_context_set_notice(context, message == NULL ? "Working..." : message, "dim-label"); -} - -static void file_tree_context_finish_operation(FileTreeContext *context) { - if (context->busy_count > 0) { - context->busy_count--; - } - - if (context->busy_count == 0) { - file_tree_context_set_notice(context, NULL, NULL); - } -} - -static FileTreeAsyncRequest *file_tree_async_request_new( - FileTreeContext *context, - FileTreeAsyncOperation operation, - const char *error_prefix -) { - FileTreeAsyncRequest *request = g_new0(FileTreeAsyncRequest, 1); - - request->root = g_object_ref(context->root); - request->ipc_client = g_object_ref(context->ipc_client); - request->workspace_id = context->workspace_id; - request->operation = operation; - request->error_prefix = g_strdup(error_prefix); - - return request; -} - -static void file_tree_async_request_thread(GTask *task, gpointer source_object, gpointer task_data, GCancellable *cancellable) { - (void)source_object; - - FileTreeAsyncRequest *request = task_data; - GError *error = NULL; - JsonNode *tree = NULL; - gboolean ok = FALSE; - - if (request->entries != NULL) { - for (guint index = 0; index < request->entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(request->entries, index); - - g_clear_pointer(&tree, json_node_unref); - switch (request->operation) { - case FILE_TREE_ASYNC_DELETE: - ok = kosmos_ipc_client_delete_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_MOVE: - ok = kosmos_ipc_client_move_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, request->target_directory_path, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_COPY: - ok = kosmos_ipc_client_copy_file_tree_entry(request->ipc_client, request->workspace_id, entry->path, request->target_directory_path, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_LIST: - ok = kosmos_ipc_client_list_file_tree(request->ipc_client, request->workspace_id, &tree, cancellable, &error); - break; - } - - if (!ok || error != NULL) { - if (tree != NULL) { - json_node_unref(tree); - } - g_task_return_error(task, error == NULL ? g_error_new(KOSMOS_IPC_ERROR, KOSMOS_IPC_ERROR_IO, "IPC request failed") : error); - return; - } - } - - g_task_return_pointer(task, tree, (GDestroyNotify)json_node_unref); - return; - } - - switch (request->operation) { - case FILE_TREE_ASYNC_LIST: - ok = kosmos_ipc_client_list_file_tree(request->ipc_client, request->workspace_id, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_DELETE: - ok = kosmos_ipc_client_delete_file_tree_entry(request->ipc_client, request->workspace_id, request->path, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_MOVE: - ok = kosmos_ipc_client_move_file_tree_entry(request->ipc_client, request->workspace_id, request->path, request->target_directory_path, &tree, cancellable, &error); - break; - case FILE_TREE_ASYNC_COPY: - ok = kosmos_ipc_client_copy_file_tree_entry(request->ipc_client, request->workspace_id, request->path, request->target_directory_path, &tree, cancellable, &error); - break; - } - - if (!ok || error != NULL) { - if (tree != NULL) { - json_node_unref(tree); - } - g_task_return_error(task, error == NULL ? g_error_new(KOSMOS_IPC_ERROR, KOSMOS_IPC_ERROR_IO, "IPC request failed") : error); - return; - } - - g_task_return_pointer(task, tree, (GDestroyNotify)json_node_unref); -} - -static void file_tree_async_request_done(GObject *source_object, GAsyncResult *result, gpointer user_data) { - (void)source_object; - (void)user_data; - - GTask *task = G_TASK(result); - FileTreeAsyncRequest *request = g_task_get_task_data(task); - FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); - GError *error = NULL; - JsonNode *tree = g_task_propagate_pointer(task, &error); - - if (context == NULL) { - g_clear_error(&error); - if (tree != NULL) { - json_node_unref(tree); - } - return; - } - - if (request->show_progress) { - file_tree_context_finish_operation(context); - } - - if (error != NULL) { - char *message = g_strdup_printf("%s: %s", request->error_prefix, error->message); - file_tree_context_set_notice(context, message, "error"); - g_free(message); - g_clear_error(&error); - file_tree_context_refresh_with_progress(context, FALSE); - return; - } - - if (request->pending_focus_path != NULL) { - file_tree_context_set_pending_focus_path(context, request->pending_focus_path); - } - if (request->clear_clipboard_on_success) { - file_tree_context_clear_clipboard(context); - } - - file_tree_context_apply_tree(context, tree); - if (tree != NULL) { - json_node_unref(tree); - } -} - -static void file_tree_async_request_start(FileTreeContext *context, FileTreeAsyncRequest *request, const char *progress_message) { - if (request->show_progress) { - file_tree_context_begin_operation(context, progress_message); - } - - GTask *task = g_task_new(request->ipc_client, NULL, file_tree_async_request_done, NULL); - g_task_set_task_data(task, request, (GDestroyNotify)file_tree_async_request_free); - g_task_run_in_thread(task, file_tree_async_request_thread); - g_object_unref(task); -} - -static void file_tree_copy_conflict_response(AdwAlertDialog *dialog, GAsyncResult *result, gpointer user_data) { - FileTreeAsyncRequest *request = user_data; - const char *response = adw_alert_dialog_choose_finish(dialog, result); - FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); - - if (context != NULL && g_strcmp0(response, "keep-both") == 0) { - gboolean batch = request->entries != NULL && request->entries->len > 1; - file_tree_async_request_start(context, request, batch ? "Copying entries..." : "Copying entry..."); - return; - } - - file_tree_async_request_free(request); -} - -static void file_tree_confirm_copy_conflict(FileTreeContext *context, FileTreeAsyncRequest *request, const char *name) { - AdwDialog *dialog = adw_alert_dialog_new("Name Already Exists", NULL); - adw_alert_dialog_format_body( - ADW_ALERT_DIALOG(dialog), - "An entry named \"%s\" already exists in this folder. Keep both and create a numbered copy?", - name - ); - adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(dialog), "cancel", "_Cancel", "keep-both", "Keep _Both", NULL); - adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(dialog), "cancel"); - adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "keep-both"); - adw_alert_dialog_choose(ADW_ALERT_DIALOG(dialog), context->root, NULL, (GAsyncReadyCallback)file_tree_copy_conflict_response, request); -} - -static void file_tree_monitor_free(gpointer data) { - GFileMonitor *monitor = data; - - g_file_monitor_cancel(monitor); - g_object_unref(monitor); -} - -static gboolean file_tree_monitor_refresh(gpointer user_data); - -static void file_tree_context_schedule_monitor_refresh(FileTreeContext *context) { - if (context->monitor_refresh_source_id != 0) { - return; - } - - context->monitor_refresh_source_id = g_timeout_add(150, file_tree_monitor_refresh, context); -} - -static gboolean file_tree_monitor_refresh(gpointer user_data) { - FileTreeContext *context = user_data; - - context->monitor_refresh_source_id = 0; - file_tree_context_refresh_with_progress(context, FALSE); - - return G_SOURCE_REMOVE; -} - -static void file_tree_monitor_changed( - GFileMonitor *monitor, - GFile *file, - GFile *other_file, - GFileMonitorEvent event, - gpointer user_data -) { - (void)monitor; - (void)file; - (void)other_file; - (void)event; - - file_tree_context_schedule_monitor_refresh(user_data); -} - -static void file_tree_context_watch_directory(FileTreeContext *context, const char *path) { - if (context->monitors->len >= FILE_TREE_MONITOR_MAX_DIRECTORIES) { - return; - } - - GFile *file = g_file_new_for_path(path); - GError *error = NULL; - GFileMonitor *monitor = g_file_monitor_directory(file, G_FILE_MONITOR_NONE, NULL, &error); - - if (monitor != NULL && context->monitors->len < FILE_TREE_MONITOR_MAX_DIRECTORIES) { - g_signal_connect(monitor, "changed", G_CALLBACK(file_tree_monitor_changed), context); - g_ptr_array_add(context->monitors, monitor); - monitor = NULL; - } - - g_clear_object(&monitor); - g_clear_error(&error); - g_object_unref(file); -} - -static void file_tree_context_watch_item(FileTreeContext *context, KosmosFileTreeItem *item, GHashTable *paths, guint depth) { - if (depth > FILE_TREE_MONITOR_MAX_DEPTH || context->monitors->len >= FILE_TREE_MONITOR_MAX_DIRECTORIES) { - return; - } - - if (kosmos_file_tree_item_is_directory(item) && item->path[0] != '\0' && g_hash_table_add(paths, g_strdup(item->path))) { - file_tree_context_watch_directory(context, item->path); - } - - if (depth == FILE_TREE_MONITOR_MAX_DEPTH) { - return; - } - - guint child_count = kosmos_file_tree_item_child_count(item); - for (guint index = 0; index < child_count; index++) { - KosmosFileTreeItem *child = g_list_model_get_item(G_LIST_MODEL(item->children), index); - - if (child != NULL && !kosmos_file_tree_item_is_message(child)) { - file_tree_context_watch_item(context, child, paths, depth + 1); - } - - g_clear_object(&child); - } -} - -static void file_tree_context_rebuild_monitors(FileTreeContext *context, KosmosFileTreeItem *root) { - g_clear_pointer(&context->monitors, g_ptr_array_unref); - context->monitors = g_ptr_array_new_with_free_func(file_tree_monitor_free); - - GHashTable *paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); - file_tree_context_watch_item(context, root, paths, 0); - g_hash_table_unref(paths); -} - -static void file_tree_context_remember_expanded(FileTreeContext *context) { - if (context->tree_model == NULL) { - return; - } - - GHashTable *expanded_paths = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); - guint row_count = g_list_model_get_n_items(G_LIST_MODEL(context->tree_model)); - - for (guint index = 0; index < row_count; index++) { - GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(context->tree_model), index); - if (row == NULL) { - continue; - } - - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); - if (kosmos_file_tree_item_is_directory(item) && !file_tree_item_is_root(context, item) && - gtk_tree_list_row_get_expanded(row)) { - g_hash_table_add(expanded_paths, g_strdup(item->path)); - } - - g_object_unref(row); - } - - g_clear_pointer(&context->expanded_paths, g_hash_table_unref); - context->expanded_paths = expanded_paths; -} - -static gboolean file_tree_context_path_was_expanded(FileTreeContext *context, const char *path) { - return context->expanded_paths != NULL && g_hash_table_contains(context->expanded_paths, path); -} - -static void file_tree_context_restore_expanded(FileTreeContext *context, GtkTreeListModel *tree_model) { - for (guint index = 0; index < g_list_model_get_n_items(G_LIST_MODEL(tree_model)); index++) { - GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(tree_model), index); - if (row == NULL) { - continue; - } - - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); - if (kosmos_file_tree_item_is_directory(item) && - (file_tree_item_is_root(context, item) || file_tree_context_path_was_expanded(context, item->path))) { - gtk_tree_list_row_set_expanded(row, TRUE); - } - - g_object_unref(row); - } -} - -static void file_tree_context_set_status(FileTreeContext *context, const char *text, const char *css_class) { - context->list = NULL; - g_clear_object(&context->tree_model); - g_clear_pointer(&context->monitors, g_ptr_array_unref); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(context->scrolled), create_status_label(text, css_class)); -} - -static void file_tree_context_apply_tree(FileTreeContext *context, JsonNode *tree) { - if (tree == NULL || !JSON_NODE_HOLDS_OBJECT(tree)) { - file_tree_context_set_status(context, "File tree is unavailable.", "dim-label"); - return; - } - - JsonObject *result = json_node_get_object(tree); - JsonObject *root = get_object_member(result, "root"); - const char *root_path = NULL; - const char *root_name = NULL; - if (root == NULL || !get_string_member(root, "path", &root_path) || !get_string_member(root, "name", &root_name)) { - file_tree_context_set_status(context, "File tree response is invalid.", "error"); - return; - } - - file_tree_context_remember_expanded(context); - g_clear_object(&context->tree_model); - if (context->pending_focus_path != NULL) { - file_tree_context_set_focused_path(context, context->pending_focus_path); - g_clear_pointer(&context->pending_focus_path, g_free); - } else if (context->focused_path == NULL) { - file_tree_context_set_focused_path(context, root_path); - } - - g_free(context->root_path); - g_free(context->root_name); - context->root_path = g_strdup(root_path); - context->root_name = g_strdup(root_name); - context->list = create_file_tree_list(context, root); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(context->scrolled), context->list); - gtk_widget_grab_focus(context->list); -} - -static void file_tree_context_apply_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix) { - if (error != NULL) { - char *message = g_strdup_printf("%s: %s", prefix, error->message); - file_tree_context_set_status(context, message, "error"); - g_free(message); - return; - } - - file_tree_context_apply_tree(context, tree); -} - -static void file_tree_context_refresh_with_progress(FileTreeContext *context, gboolean show_progress) { - if (context->monitor_refresh_source_id != 0) { - g_source_remove(context->monitor_refresh_source_id); - context->monitor_refresh_source_id = 0; - } - - FileTreeAsyncRequest *request = file_tree_async_request_new(context, FILE_TREE_ASYNC_LIST, "Failed to load file tree"); - request->show_progress = show_progress; - file_tree_async_request_start(context, request, "Refreshing file tree..."); -} - -static void file_tree_context_refresh(FileTreeContext *context) { - file_tree_context_refresh_with_progress(context, TRUE); -} - -static const char *entry_icon_name(KosmosFileTreeItem *item) { - if (kosmos_file_tree_item_is_directory(item)) { - return "folder-symbolic"; - } - if (kosmos_file_tree_item_is_message(item)) { - return "dialog-information-symbolic"; - } - if (g_strcmp0(item->kind, "symlink") == 0) { - return "emblem-symbolic-link-symbolic"; - } - - return "text-x-generic-symbolic"; -} - -static void append_message_child(KosmosFileTreeItem *item, const char *message) { - KosmosFileTreeItem *message_item = kosmos_file_tree_item_new_message(message); - g_list_store_append(item->children, message_item); - g_object_unref(message_item); -} - -static KosmosFileTreeItem *file_tree_item_from_entry(JsonObject *entry) { - const char *name = NULL; - const char *path = NULL; - const char *kind = NULL; - const char *read_error = NULL; - gboolean children_truncated = FALSE; - - if (!get_string_member(entry, "name", &name) || !get_string_member(entry, "kind", &kind)) { - return kosmos_file_tree_item_new_message("Invalid file tree entry."); - } - - get_string_member(entry, "path", &path); - get_string_member(entry, "readError", &read_error); - get_bool_member(entry, "childrenTruncated", &children_truncated); - - KosmosFileTreeItem *item = kosmos_file_tree_item_new(name, path, kind); - - JsonArray *children = get_array_member(entry, "children"); - guint child_count = children == NULL ? 0 : json_array_get_length(children); - for (guint index = 0; index < child_count; index++) { - JsonNode *child_node = json_array_get_element(children, index); - if (child_node == NULL || !JSON_NODE_HOLDS_OBJECT(child_node)) { - append_message_child(item, "Invalid file tree entry."); - continue; - } - - KosmosFileTreeItem *child = file_tree_item_from_entry(json_node_get_object(child_node)); - g_list_store_append(item->children, child); - g_object_unref(child); - } - - if (children_truncated) { - append_message_child(item, "Directory listing is truncated."); - } - if (read_error != NULL) { - append_message_child(item, read_error); - } - - return item; -} - -static GListModel *create_child_model(gpointer item, gpointer user_data) { - (void)user_data; - - KosmosFileTreeItem *entry = KOSMOS_FILE_TREE_ITEM(item); - if (kosmos_file_tree_item_child_count(entry) == 0) { - return NULL; - } - - return G_LIST_MODEL(g_object_ref(entry->children)); -} - -static void setup_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; - (void)user_data; - - GtkWidget *hit_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); - GtkWidget *icon = gtk_image_new(); - GtkWidget *label = gtk_label_new(NULL); - GtkWidget *entry = gtk_entry_new(); - GtkDragSource *drag_source = gtk_drag_source_new(); - GtkDropTarget *drop_target = gtk_drop_target_new(KOSMOS_TYPE_FILE_TREE_ITEM, GDK_ACTION_MOVE); - GtkEventController *entry_key = gtk_event_controller_key_new(); - GtkEventController *entry_focus = gtk_event_controller_focus_new(); - - gtk_list_item_set_selectable(list_item, TRUE); - gtk_widget_set_margin_top(row, 3); - gtk_widget_set_margin_bottom(row, 3); - gtk_widget_set_margin_end(row, 10); - gtk_widget_set_hexpand(hit_row, TRUE); - gtk_widget_set_halign(hit_row, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(row, TRUE); - gtk_widget_set_halign(row, GTK_ALIGN_FILL); - gtk_label_set_xalign(GTK_LABEL(label), 0.0f); - gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_hexpand(entry, TRUE); - gtk_widget_set_visible(entry, FALSE); - - gtk_box_append(GTK_BOX(row), icon); - gtk_box_append(GTK_BOX(row), label); - gtk_box_append(GTK_BOX(row), entry); - gtk_box_append(GTK_BOX(hit_row), row); - g_object_set_data(G_OBJECT(hit_row), "entry-row", row); - g_object_set_data(G_OBJECT(hit_row), "entry-icon", icon); - g_object_set_data(G_OBJECT(hit_row), "entry-label", label); - g_object_set_data(G_OBJECT(hit_row), "entry-editor", entry); - gtk_drag_source_set_actions(drag_source, GDK_ACTION_MOVE); - gtk_drop_target_set_preload(drop_target, TRUE); - g_signal_connect(drag_source, "prepare", G_CALLBACK(file_tree_drag_prepare), NULL); - g_signal_connect(drop_target, "enter", G_CALLBACK(file_tree_drop_enter), NULL); - g_signal_connect(drop_target, "motion", G_CALLBACK(file_tree_drop_motion), NULL); - g_signal_connect(drop_target, "leave", G_CALLBACK(file_tree_drop_leave), NULL); - g_signal_connect(drop_target, "drop", G_CALLBACK(file_tree_drop), NULL); - gtk_widget_add_controller(hit_row, GTK_EVENT_CONTROLLER(drag_source)); - gtk_widget_add_controller(hit_row, GTK_EVENT_CONTROLLER(drop_target)); - g_signal_connect(entry, "activate", G_CALLBACK(inline_rename_commit), NULL); - g_signal_connect(entry_key, "key-pressed", G_CALLBACK(inline_rename_key_pressed), NULL); - g_signal_connect(entry_focus, "leave", G_CALLBACK(inline_rename_focus_leave), NULL); - gtk_widget_add_controller(entry, entry_key); - gtk_widget_add_controller(entry, entry_focus); - - gtk_list_item_set_child(list_item, hit_row); -} - -static void set_tree_row_data(GtkWidget *widget, GtkTreeListRow *tree_row) { - g_object_set_data_full(G_OBJECT(widget), "tree-row", g_object_ref(tree_row), g_object_unref); -} - -static GtkTreeListRow *tree_row_data(GtkWidget *widget) { - GtkTreeListRow *tree_row = g_object_get_data(G_OBJECT(widget), "tree-row"); - - return tree_row == NULL ? NULL : g_object_ref(tree_row); -} - -static void bind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; - - FileTreeContext *context = user_data; - GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item); - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); - GtkWidget *hit_row = gtk_list_item_get_child(list_item); - GtkWidget *row = g_object_get_data(G_OBJECT(hit_row), "entry-row"); - GtkWidget *icon = g_object_get_data(G_OBJECT(hit_row), "entry-icon"); - GtkWidget *label = g_object_get_data(G_OBJECT(hit_row), "entry-label"); - GtkWidget *entry = g_object_get_data(G_OBJECT(hit_row), "entry-editor"); - guint depth = gtk_tree_list_row_get_depth(tree_row); - gboolean editing = g_strcmp0(context->editing_path, item->path) == 0; - - gtk_widget_set_margin_start(row, 10 + (int)(depth * 18)); - gtk_image_set_from_icon_name(GTK_IMAGE(icon), entry_icon_name(item)); - gtk_label_set_text(GTK_LABEL(label), item->name); - gtk_editable_set_text(GTK_EDITABLE(entry), item->name); - gtk_widget_set_visible(label, !editing); - gtk_widget_set_visible(entry, editing); - gtk_widget_set_tooltip_text(hit_row, item->path[0] == '\0' ? NULL : item->path); - set_tree_row_data(hit_row, tree_row); - g_object_set_data(G_OBJECT(hit_row), "file-tree-context", context); - g_object_set_data_full(G_OBJECT(hit_row), "file-tree-item", g_object_ref(item), g_object_unref); - g_object_set_data(G_OBJECT(entry), "file-tree-context", context); - g_object_set_data_full(G_OBJECT(entry), "file-tree-item", g_object_ref(item), g_object_unref); - - if (kosmos_file_tree_item_is_message(item)) { - gtk_widget_add_css_class(label, "dim-label"); - gtk_widget_add_css_class(icon, "dim-label"); - } else { - gtk_widget_remove_css_class(label, "dim-label"); - gtk_widget_remove_css_class(icon, "dim-label"); - } - - if (editing) { - gtk_widget_grab_focus(entry); - gtk_editable_select_region(GTK_EDITABLE(entry), 0, -1); - } -} - -static GtkTreeListRow *tree_row_in_descendants(GtkWidget *widget) { - if (widget == NULL) { - return NULL; - } - - GtkTreeListRow *tree_row = tree_row_data(widget); - if (tree_row != NULL) { - return tree_row; - } - - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - GtkTreeListRow *child_row = tree_row_in_descendants(child); - if (child_row != NULL) { - return child_row; - } - } - - return NULL; -} - -static GtkTreeListRow *tree_row_in_ancestors(GtkWidget *widget) { - for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { - GtkTreeListRow *tree_row = tree_row_data(ancestor); - if (tree_row != NULL) { - return tree_row; - } - } - - return NULL; -} - -static GtkTreeListRow *tree_row_for_picked_widget(GtkWidget *widget) { - gboolean picked_list_item = FALSE; - - for (GtkWidget *ancestor = widget; ancestor != NULL; ancestor = gtk_widget_get_parent(ancestor)) { - if (g_strcmp0(G_OBJECT_TYPE_NAME(ancestor), "GtkListItemWidget") == 0) { - picked_list_item = TRUE; - break; - } - } - - if (!picked_list_item) { - return NULL; - } - - GtkTreeListRow *tree_row = tree_row_in_ancestors(widget); - - return tree_row == NULL ? tree_row_in_descendants(widget) : tree_row; -} - -static gboolean file_tree_model_index_for_path(GtkTreeListModel *tree_model, const char *path, guint *position) { - if (tree_model == NULL || path == NULL) { - return FALSE; - } - - guint row_count = g_list_model_get_n_items(G_LIST_MODEL(tree_model)); - for (guint index = 0; index < row_count; index++) { - GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(tree_model), index); - if (row == NULL) { - continue; - } - - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); - gboolean matches = g_strcmp0(item->path, path) == 0; - g_object_unref(row); - - if (matches) { - *position = index; - return TRUE; - } - } - - return FALSE; -} - -static void file_tree_context_select_focused_path(FileTreeContext *context) { - if (context->list == NULL || context->focused_path == NULL) { - return; - } - - GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); - guint position = 0; - if (file_tree_model_index_for_path(context->tree_model, context->focused_path, &position)) { - gtk_selection_model_select_item(selection, position, TRUE); - } -} - -static void file_tree_clicked(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)n_press; - - FileTreeContext *context = user_data; - GdkModifierType state = gtk_event_controller_get_current_event_state(GTK_EVENT_CONTROLLER(gesture)); - gboolean multi_select_click = - (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK)) != 0; - - if (context->list == NULL) { - return; - } - - GtkWidget *source = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); - graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - GtkTreeListRow *tree_row = NULL; - - if (gtk_widget_compute_point(source, context->list, &source_point, &list_point)) { - GtkWidget *picked = gtk_widget_pick(context->list, list_point.x, list_point.y, GTK_PICK_DEFAULT); - tree_row = tree_row_for_picked_widget(picked); - } - - if (tree_row != NULL) { - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); - if (!kosmos_file_tree_item_is_message(item)) { - file_tree_context_set_focused_path(context, item->path); - } - if (!multi_select_click && gtk_tree_list_row_is_expandable(tree_row)) { - gtk_tree_list_row_set_expanded(tree_row, !gtk_tree_list_row_get_expanded(tree_row)); - } - } - - g_clear_object(&tree_row); -} - -static GtkWindow *file_tree_context_window(FileTreeContext *context) { - GtkRoot *root = gtk_widget_get_root(context->root); - - return GTK_IS_WINDOW(root) ? GTK_WINDOW(root) : NULL; -} - -static gboolean file_tree_item_is_root(FileTreeContext *context, KosmosFileTreeItem *item) { - return context->root_path != NULL && g_strcmp0(context->root_path, item->path) == 0; -} - -static void apply_file_tree_operation_response(FileTreeContext *context, JsonNode *tree, GError *error, const char *prefix) { - file_tree_context_apply_response(context, tree, error, prefix); - g_clear_error(&error); - if (tree != NULL) { - json_node_unref(tree); - } -} - -static gboolean path_is_equal_or_descendant(const char *path, const char *parent) { - gsize parent_len = strlen(parent); - - return g_strcmp0(path, parent) == 0 || - (g_str_has_prefix(path, parent) && G_IS_DIR_SEPARATOR(path[parent_len])); -} - -static gboolean file_tree_entry_refs_contain_ancestor(GPtrArray *entries, const char *path) { - for (guint index = 0; index < entries->len; index++) { - FileTreeEntryRef *entry = g_ptr_array_index(entries, index); - if (path_is_equal_or_descendant(path, entry->path)) { - return TRUE; - } - } - - return FALSE; -} - -static void file_tree_entry_refs_remove_descendants(GPtrArray *entries, const char *path) { - for (guint index = entries->len; index > 0; index--) { - FileTreeEntryRef *entry = g_ptr_array_index(entries, index - 1); - if (path_is_equal_or_descendant(entry->path, path)) { - g_ptr_array_remove_index(entries, index - 1); - } - } -} - -static void file_tree_entry_refs_add_filtered(GPtrArray *entries, KosmosFileTreeItem *item) { - if (file_tree_entry_refs_contain_ancestor(entries, item->path)) { - return; - } - - file_tree_entry_refs_remove_descendants(entries, item->path); - g_ptr_array_add(entries, file_tree_entry_ref_new_from_item(item)); -} - -static GPtrArray *file_tree_context_selected_entries(FileTreeContext *context, gboolean include_root) { - GPtrArray *entries = file_tree_entry_ref_array_new(); - - if (context->list == NULL || context->tree_model == NULL) { - return entries; - } - - GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); - guint row_count = g_list_model_get_n_items(G_LIST_MODEL(context->tree_model)); - for (guint index = 0; index < row_count; index++) { - if (!gtk_selection_model_is_selected(selection, index)) { - continue; - } - - GtkTreeListRow *row = g_list_model_get_item(G_LIST_MODEL(context->tree_model), index); - if (row == NULL) { - continue; - } - - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(row)); - if (!kosmos_file_tree_item_is_message(item) && (include_root || !file_tree_item_is_root(context, item))) { - file_tree_entry_refs_add_filtered(entries, item); - } - - g_object_unref(row); - } - - return entries; -} - -static gboolean file_tree_context_path_is_selected(FileTreeContext *context, const char *path) { - guint position = 0; - - if (context->list == NULL || !file_tree_model_index_for_path(context->tree_model, path, &position)) { - return FALSE; - } - - GtkSelectionModel *selection = gtk_list_view_get_model(GTK_LIST_VIEW(context->list)); - return gtk_selection_model_is_selected(selection, position); -} - -static GPtrArray *file_tree_context_action_entries(FileTreeContext *context, KosmosFileTreeItem *item) { - if (!file_tree_item_is_root(context, item) && file_tree_context_path_is_selected(context, item->path)) { - GPtrArray *selected = file_tree_context_selected_entries(context, FALSE); - if (selected->len > 0) { - return selected; - } - - g_ptr_array_unref(selected); - } - - GPtrArray *entries = file_tree_entry_ref_array_new(); - if (!file_tree_item_is_root(context, item)) { - g_ptr_array_add(entries, file_tree_entry_ref_new_from_item(item)); - } - return entries; -} - -static gboolean file_tree_item_can_drag(FileTreeContext *context, KosmosFileTreeItem *item) { - return item != NULL && !kosmos_file_tree_item_is_message(item) && !file_tree_item_is_root(context, item); -} - -static gboolean file_tree_item_can_drop_on( - FileTreeContext *context, - KosmosFileTreeItem *source, - KosmosFileTreeItem *target -) { - if (!file_tree_item_can_drag(context, source) || target == NULL || !kosmos_file_tree_item_is_directory(target)) { - return FALSE; - } - - char *source_parent = g_path_get_dirname(source->path); - gboolean same_parent = g_strcmp0(source_parent, target->path) == 0; - g_free(source_parent); - - if (same_parent || g_strcmp0(source->path, target->path) == 0) { - return FALSE; - } - - return !kosmos_file_tree_item_is_directory(source) || !path_is_equal_or_descendant(target->path, source->path); -} - -static GdkContentProvider *file_tree_drag_prepare(GtkDragSource *source, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(source)); - FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); - KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(row), "file-tree-item"); - - if (context == NULL || !file_tree_item_can_drag(context, item)) { - return NULL; - } - - return gdk_content_provider_new_typed(KOSMOS_TYPE_FILE_TREE_ITEM, item); -} - -static KosmosFileTreeItem *file_tree_drop_source_item(GtkDropTarget *target) { - const GValue *value = gtk_drop_target_get_value(target); - - return value == NULL ? NULL : g_value_get_object(value); -} - -static GdkDragAction file_tree_drop_highlight(GtkDropTarget *target) { - GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); - FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); - KosmosFileTreeItem *source = file_tree_drop_source_item(target); - KosmosFileTreeItem *target_item = g_object_get_data(G_OBJECT(row), "file-tree-item"); - gboolean valid = context != NULL && file_tree_item_can_drop_on(context, source, target_item); - - if (valid) { - gtk_widget_add_css_class(row, "file-tree-drop-target"); - return GDK_ACTION_MOVE; - } - - gtk_widget_remove_css_class(row, "file-tree-drop-target"); - return 0; -} - -static GdkDragAction file_tree_drop_enter(GtkDropTarget *target, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - return file_tree_drop_highlight(target); -} - -static GdkDragAction file_tree_drop_motion(GtkDropTarget *target, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - return file_tree_drop_highlight(target); -} - -static void file_tree_drop_leave(GtkDropTarget *target, gpointer user_data) { - (void)user_data; - - GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); - gtk_widget_remove_css_class(row, "file-tree-drop-target"); -} - -static gboolean file_tree_drop(GtkDropTarget *target, const GValue *value, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - GtkWidget *row = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(target)); - FileTreeContext *context = g_object_get_data(G_OBJECT(row), "file-tree-context"); - KosmosFileTreeItem *source = g_value_get_object(value); - KosmosFileTreeItem *target_item = g_object_get_data(G_OBJECT(row), "file-tree-item"); - gtk_widget_remove_css_class(row, "file-tree-drop-target"); - - if (context == NULL || !file_tree_item_can_drop_on(context, source, target_item)) { - return FALSE; - } - - char *focused_path = file_tree_path_child(target_item->path, source->name); - FileTreeAsyncRequest *request = file_tree_async_request_new(context, FILE_TREE_ASYNC_MOVE, "Failed to move entry"); - request->path = g_strdup(source->path); - request->target_directory_path = g_strdup(target_item->path); - request->pending_focus_path = g_strdup(focused_path); - request->show_progress = TRUE; - file_tree_async_request_start(context, request, "Moving entry..."); - g_free(focused_path); - return TRUE; -} - -static void inline_rename_commit(GtkWidget *entry) { - FileTreeContext *context = g_object_get_data(G_OBJECT(entry), "file-tree-context"); - KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(entry), "file-tree-item"); - - if (context == NULL || item == NULL || g_strcmp0(context->editing_path, item->path) != 0) { - return; - } - - const char *name = gtk_editable_get_text(GTK_EDITABLE(entry)); - if (name == NULL || name[0] == '\0' || g_strcmp0(name, item->name) == 0) { - inline_rename_cancel(entry); - return; - } - - char *parent = file_tree_path_parent(item->path); - char *focused_path = file_tree_path_child(parent, name); - file_tree_context_set_pending_focus_path(context, focused_path); - file_tree_context_clear_editing_path(context); - - GError *error = NULL; - JsonNode *tree = NULL; - kosmos_ipc_client_rename_file_tree_entry( - context->ipc_client, - context->workspace_id, - item->path, - name, - &tree, - NULL, - &error - ); - apply_file_tree_operation_response(context, tree, error, "Failed to rename entry"); - - g_free(focused_path); - g_free(parent); -} - -static void inline_rename_cancel(GtkWidget *entry) { - FileTreeContext *context = g_object_get_data(G_OBJECT(entry), "file-tree-context"); - KosmosFileTreeItem *item = g_object_get_data(G_OBJECT(entry), "file-tree-item"); - - if (context == NULL || item == NULL || g_strcmp0(context->editing_path, item->path) != 0) { - return; - } - - file_tree_context_clear_editing_path(context); - file_tree_context_refresh(context); -} - -static gboolean inline_rename_key_pressed( - GtkEventControllerKey *controller, - guint keyval, - guint keycode, - GdkModifierType state, - gpointer user_data -) { - (void)keycode; - (void)state; - (void)user_data; - - if (keyval == GDK_KEY_Escape) { - inline_rename_cancel(gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller))); - return TRUE; - } - - return FALSE; -} - -static void inline_rename_focus_leave(GtkEventControllerFocus *controller, gpointer user_data) { - (void)user_data; - - inline_rename_commit(gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller))); -} - -static void file_tree_name_dialog_set_error(FileTreeNameDialog *request, const char *message) { - gtk_label_set_text(GTK_LABEL(request->error_label), message); - gtk_widget_set_visible(request->error_label, TRUE); - gtk_widget_add_css_class(request->entry, "error"); - gtk_widget_grab_focus(request->entry); -} - -static gboolean file_tree_name_dialog_validate(FileTreeNameDialog *request, const char *name) { - if (!file_tree_name_is_valid(name)) { - file_tree_name_dialog_set_error(request, "Enter a valid name."); - return FALSE; - } - - char *target = file_tree_path_child(request->path, name); - gboolean exists = g_file_test(target, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_SYMLINK); - g_free(target); - - if (exists) { - file_tree_name_dialog_set_error(request, "An entry with that name already exists."); - return FALSE; - } - - gtk_widget_remove_css_class(request->entry, "error"); - gtk_widget_set_visible(request->error_label, FALSE); - return TRUE; -} - -static void file_tree_name_dialog_entry_changed(GtkEditable *editable, gpointer user_data) { - FileTreeNameDialog *request = user_data; - - gtk_widget_remove_css_class(GTK_WIDGET(editable), "error"); - gtk_widget_set_visible(request->error_label, FALSE); -} - -static void file_tree_name_dialog_accept(GtkWidget *widget, gpointer user_data) { - (void)widget; - - FileTreeNameDialog *request = user_data; - gboolean created = FALSE; - - const char *name = gtk_editable_get_text(GTK_EDITABLE(request->entry)); - GError *error = NULL; - JsonNode *tree = NULL; - - if (!file_tree_name_dialog_validate(request, name)) { - return; - } - - switch (request->operation) { - case FILE_TREE_DIALOG_CREATE_FILE: - { - char *focused_path = file_tree_path_child(request->path, name); - file_tree_context_set_pending_focus_path(request->context, focused_path); - g_free(focused_path); - } - created = kosmos_ipc_client_create_file_tree_entry( - request->context->ipc_client, - request->context->workspace_id, - request->path, - name, - FALSE, - &tree, - NULL, - &error - ); - break; - case FILE_TREE_DIALOG_CREATE_DIRECTORY: - { - char *focused_path = file_tree_path_child(request->path, name); - file_tree_context_set_pending_focus_path(request->context, focused_path); - g_free(focused_path); - } - created = kosmos_ipc_client_create_file_tree_entry( - request->context->ipc_client, - request->context->workspace_id, - request->path, - name, - TRUE, - &tree, - NULL, - &error - ); - break; - } - - if (!created || error != NULL) { - const char *message = error == NULL ? "Failed to create entry." : error->message; - g_clear_pointer(&request->context->pending_focus_path, g_free); - file_tree_name_dialog_set_error(request, message); - g_clear_error(&error); - if (tree != NULL) { - json_node_unref(tree); - } - return; - } - - file_tree_context_apply_tree(request->context, tree); - if (tree != NULL) { - json_node_unref(tree); - } - - adw_dialog_close(request->dialog); -} - -static void file_tree_name_dialog_cancel(GtkWidget *widget, gpointer user_data) { - (void)widget; - - FileTreeNameDialog *request = user_data; - adw_dialog_close(request->dialog); -} - -static void show_name_dialog( - FileTreeContext *context, - FileTreeDialogOperation operation, - const char *path, - const char *initial_name -) { - const char *title = "Name"; - const char *accept = "_Save"; - - if (operation == FILE_TREE_DIALOG_CREATE_FILE) { - title = "New File"; - accept = "_Create"; - } else if (operation == FILE_TREE_DIALOG_CREATE_DIRECTORY) { - title = "New Folder"; - accept = "_Create"; - } - - AdwDialog *dialog = adw_dialog_new(); - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); - GtkWidget *entry = gtk_entry_new(); - GtkWidget *error_label = gtk_label_new(NULL); - GtkWidget *buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); - GtkWidget *cancel_button = gtk_button_new_with_mnemonic("_Cancel"); - GtkWidget *accept_button = gtk_button_new_with_mnemonic(accept); - FileTreeNameDialog *request = g_new0(FileTreeNameDialog, 1); - - request->context = context; - request->operation = operation; - request->path = g_strdup(path); - request->dialog = dialog; - request->entry = entry; - request->error_label = error_label; - - adw_dialog_set_title(dialog, title); - gtk_widget_set_margin_top(content, 18); - gtk_widget_set_margin_bottom(content, 18); - gtk_widget_set_margin_start(content, 18); - gtk_widget_set_margin_end(content, 18); - gtk_editable_set_text(GTK_EDITABLE(entry), initial_name == NULL ? "" : initial_name); - gtk_widget_set_hexpand(entry, TRUE); - gtk_label_set_xalign(GTK_LABEL(error_label), 0.0f); - gtk_label_set_wrap(GTK_LABEL(error_label), TRUE); - gtk_widget_add_css_class(error_label, "error"); - gtk_widget_set_visible(error_label, FALSE); - gtk_widget_set_hexpand(buttons, TRUE); - gtk_widget_set_halign(buttons, GTK_ALIGN_END); - gtk_widget_add_css_class(accept_button, "suggested-action"); - - gtk_box_append(GTK_BOX(content), entry); - gtk_box_append(GTK_BOX(content), error_label); - gtk_box_append(GTK_BOX(buttons), cancel_button); - gtk_box_append(GTK_BOX(buttons), accept_button); - gtk_box_append(GTK_BOX(content), buttons); - adw_dialog_set_child(dialog, content); - g_object_set_data_full(G_OBJECT(dialog), "file-tree-name-request", request, (GDestroyNotify)file_tree_name_dialog_free); - g_signal_connect(entry, "changed", G_CALLBACK(file_tree_name_dialog_entry_changed), request); - g_signal_connect(entry, "activate", G_CALLBACK(file_tree_name_dialog_accept), request); - g_signal_connect(cancel_button, "clicked", G_CALLBACK(file_tree_name_dialog_cancel), request); - g_signal_connect(accept_button, "clicked", G_CALLBACK(file_tree_name_dialog_accept), request); - adw_dialog_present(dialog, context->root); - gtk_widget_grab_focus(entry); -} - -static void file_tree_delete_dialog_response(AdwAlertDialog *dialog, GAsyncResult *result, gpointer user_data) { - (void)dialog; - - FileTreeDeleteDialog *request = user_data; - FileTreeContext *context = g_object_get_data(G_OBJECT(request->root), "file-tree-context"); - const char *response = adw_alert_dialog_choose_finish(dialog, result); - - if (context != NULL && g_strcmp0(response, "delete") == 0) { - FileTreeEntryRef *first_entry = g_ptr_array_index(request->entries, 0); - char *focused_path = file_tree_path_parent(first_entry->path); - FileTreeAsyncRequest *async_request = file_tree_async_request_new(context, FILE_TREE_ASYNC_DELETE, "Failed to delete entry"); - - async_request->entries = file_tree_entry_ref_array_copy(request->entries); - async_request->pending_focus_path = g_strdup(focused_path); - async_request->show_progress = TRUE; - async_request->clear_clipboard_on_success = file_tree_context_clipboard_intersects_entries(context, request->entries); - file_tree_async_request_start(context, async_request, request->entries->len == 1 ? "Deleting entry..." : "Deleting entries..."); - g_free(focused_path); - } - - file_tree_delete_dialog_free(request); -} - -static void show_delete_dialog(FileTreeContext *context, GPtrArray *entries) { - if (entries->len == 0) { - return; - } - - FileTreeEntryRef *first_entry = g_ptr_array_index(entries, 0); - const char *type = g_strcmp0(first_entry->kind, "directory") == 0 ? "folder" : "file"; - char *body = entries->len == 1 - ? g_strdup_printf("%s\n\nThis cannot be undone.", first_entry->path) - : g_strdup_printf("Delete %u selected entries?\n\nThis cannot be undone.", entries->len); - AdwDialog *dialog = adw_alert_dialog_new(NULL, body); - FileTreeDeleteDialog *request = g_new0(FileTreeDeleteDialog, 1); - - if (entries->len == 1) { - adw_alert_dialog_format_heading(ADW_ALERT_DIALOG(dialog), "Delete %s \"%s\"?", type, first_entry->name); - } else { - adw_alert_dialog_set_heading(ADW_ALERT_DIALOG(dialog), "Delete Selected Entries?"); - } - adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(dialog), "cancel", "_Cancel", "delete", "_Delete", NULL); - adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(dialog), "cancel"); - adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "cancel"); - adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(dialog), "delete", ADW_RESPONSE_DESTRUCTIVE); - - g_free(body); - request->context = context; - request->root = g_object_ref(context->root); - request->entries = file_tree_entry_ref_array_copy(entries); - - adw_alert_dialog_choose(ADW_ALERT_DIALOG(dialog), context->root, NULL, (GAsyncReadyCallback)file_tree_delete_dialog_response, request); -} - -static void reveal_file_tree_entry(FileTreeContext *context, KosmosFileTreeItem *item) { - GFile *file = g_file_new_for_path(item->path); - GFile *target = kosmos_file_tree_item_is_directory(item) ? g_object_ref(file) : g_file_get_parent(file); - - if (target == NULL) { - target = g_object_ref(file); - } - - char *uri = g_file_get_uri(target); - GtkUriLauncher *launcher = gtk_uri_launcher_new(uri); - gtk_uri_launcher_launch(launcher, file_tree_context_window(context), NULL, NULL, NULL); - - g_object_unref(launcher); - g_free(uri); - g_object_unref(target); - g_object_unref(file); -} - -static char *file_tree_menu_parent_path(const char *path, const char *kind) { - return g_strcmp0(kind, "directory") == 0 ? g_strdup(path) : g_path_get_dirname(path); -} - -static FileTreeMenuAction *file_tree_menu_action_new( - FileTreeContext *context, - FileTreeMenuOperation operation, - KosmosFileTreeItem *item, - GtkWidget *popover -) { - FileTreeMenuAction *action = g_new0(FileTreeMenuAction, 1); - - action->context = context; - action->operation = operation; - action->path = g_strdup(item->path); - action->kind = g_strdup(item->kind); - action->name = g_strdup(item->name); - action->entries = file_tree_context_action_entries(context, item); - action->popover = g_object_ref(popover); - - return action; -} - -static void file_tree_menu_button_clicked(GtkButton *button, gpointer user_data) { - (void)button; - - FileTreeMenuAction *action = user_data; - gtk_popover_popdown(GTK_POPOVER(action->popover)); - - switch (action->operation) { - case FILE_TREE_MENU_NEW_FILE: { - char *parent = file_tree_menu_parent_path(action->path, action->kind); - show_name_dialog(action->context, FILE_TREE_DIALOG_CREATE_FILE, parent, ""); - g_free(parent); - break; - } - case FILE_TREE_MENU_NEW_FOLDER: { - char *parent = file_tree_menu_parent_path(action->path, action->kind); - show_name_dialog(action->context, FILE_TREE_DIALOG_CREATE_DIRECTORY, parent, ""); - g_free(parent); - break; - } - case FILE_TREE_MENU_RENAME: - file_tree_context_set_focused_path(action->context, action->path); - file_tree_context_set_editing_path(action->context, action->path); - file_tree_context_refresh(action->context); - break; - case FILE_TREE_MENU_DELETE: - show_delete_dialog(action->context, action->entries); - break; - case FILE_TREE_MENU_REFRESH: - file_tree_context_refresh(action->context); - break; - case FILE_TREE_MENU_CUT: { - file_tree_context_store_clipboard(action->context, action->entries, TRUE); - break; - } - case FILE_TREE_MENU_COPY: { - file_tree_context_store_clipboard(action->context, action->entries, FALSE); - break; - } - case FILE_TREE_MENU_PASTE: { - if (!file_tree_context_has_clipboard(action->context)) { - break; - } - - FileTreeEntryRef *first_clipboard_entry = g_ptr_array_index(action->context->clipboard_entries, 0); - char *target_directory = file_tree_paste_target_directory(action->path, action->kind); - char *conflict_path = file_tree_path_child(target_directory, first_clipboard_entry->name); - gboolean was_cut = action->context->clipboard_cut; - const char *conflict_name = was_cut ? NULL : file_tree_context_clipboard_conflict_name(action->context, target_directory); - char *focused_path = was_cut - ? g_strdup(conflict_path) - : file_tree_available_copy_path(target_directory, first_clipboard_entry->name); - FileTreeAsyncRequest *request = file_tree_async_request_new( - action->context, - was_cut ? FILE_TREE_ASYNC_MOVE : FILE_TREE_ASYNC_COPY, - was_cut ? "Failed to move entry" : "Failed to copy entry" - ); - - request->entries = file_tree_entry_ref_array_copy(action->context->clipboard_entries); - request->target_directory_path = g_strdup(target_directory); - request->pending_focus_path = g_strdup(focused_path); - request->show_progress = TRUE; - request->clear_clipboard_on_success = was_cut; - - if (conflict_name != NULL) { - file_tree_confirm_copy_conflict(action->context, request, conflict_name); - } else { - file_tree_async_request_start(action->context, request, was_cut ? "Moving entries..." : "Copying entries..."); - } - - g_free(focused_path); - g_free(conflict_path); - g_free(target_directory); - break; - } - case FILE_TREE_MENU_REVEAL: { - KosmosFileTreeItem item = { - .name = action->name, - .path = action->path, - .kind = action->kind, - .children = NULL, - }; - reveal_file_tree_entry(action->context, &item); - break; - } - } -} - -static void append_file_tree_menu_item(GMenu *menu, const char *id) { - GMenuItem *menu_item = g_menu_item_new(NULL, NULL); - - g_menu_item_set_attribute(menu_item, "custom", "s", id); - g_menu_append_item(menu, menu_item); - g_object_unref(menu_item); -} - -static GtkWidget *create_file_tree_menu_button( - const char *label_text, - FileTreeContext *context, - FileTreeMenuOperation operation, - KosmosFileTreeItem *item, - GtkWidget *popover -) { - GtkWidget *button = gtk_button_new(); - GtkWidget *label = gtk_label_new(label_text); - FileTreeMenuAction *action = file_tree_menu_action_new(context, operation, item, popover); - - gtk_label_set_xalign(GTK_LABEL(label), 0.0f); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_halign(label, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(button, TRUE); - gtk_widget_set_halign(button, GTK_ALIGN_FILL); - gtk_widget_add_css_class(button, "flat"); - gtk_button_set_child(GTK_BUTTON(button), label); - g_object_set_data_full(G_OBJECT(button), "file-tree-menu-action", action, (GDestroyNotify)file_tree_menu_action_free); - g_signal_connect(button, "clicked", G_CALLBACK(file_tree_menu_button_clicked), action); - - return button; -} - -static void add_file_tree_menu_button( - GtkPopoverMenu *popover, - const char *id, - const char *label, - FileTreeContext *context, - FileTreeMenuOperation operation, - KosmosFileTreeItem *item -) { - GtkWidget *button = create_file_tree_menu_button(label, context, operation, item, GTK_WIDGET(popover)); - gtk_popover_menu_add_child(popover, button, id); -} - -static GMenu *create_file_tree_menu_model(FileTreeContext *context, KosmosFileTreeItem *item) { - GMenu *menu = g_menu_new(); - GMenu *create_section = g_menu_new(); - GMenu *entry_section = g_menu_new(); - GMenu *clipboard_section = g_menu_new(); - GMenu *location_section = g_menu_new(); - GPtrArray *action_entries = file_tree_context_action_entries(context, item); - gboolean batch = action_entries->len > 1; - - append_file_tree_menu_item(create_section, "new-file"); - append_file_tree_menu_item(create_section, "new-folder"); - g_menu_append_section(menu, NULL, G_MENU_MODEL(create_section)); - - if (!file_tree_item_is_root(context, item)) { - if (!batch) { - append_file_tree_menu_item(entry_section, "rename"); - } - append_file_tree_menu_item(entry_section, "cut"); - append_file_tree_menu_item(entry_section, "copy"); - append_file_tree_menu_item(entry_section, "delete"); - g_menu_append_section(menu, NULL, G_MENU_MODEL(entry_section)); - } - - if (file_tree_context_has_clipboard(context)) { - append_file_tree_menu_item(clipboard_section, "paste"); - g_menu_append_section(menu, NULL, G_MENU_MODEL(clipboard_section)); - } - - append_file_tree_menu_item(location_section, "reveal"); - append_file_tree_menu_item(location_section, "refresh"); - g_menu_append_section(menu, NULL, G_MENU_MODEL(location_section)); - - g_object_unref(create_section); - g_object_unref(entry_section); - g_object_unref(clipboard_section); - g_object_unref(location_section); - g_ptr_array_unref(action_entries); - - return menu; -} - -static void show_file_tree_menu(FileTreeContext *context, KosmosFileTreeItem *item, double x, double y) { - GMenu *menu = create_file_tree_menu_model(context, item); - GtkWidget *popover = gtk_popover_menu_new_from_model(NULL); - gboolean root = file_tree_item_is_root(context, item); - GPtrArray *action_entries = file_tree_context_action_entries(context, item); - gboolean batch = action_entries->len > 1; - char *cut_label = batch ? g_strdup_printf("Cut %u Entries", action_entries->len) : g_strdup("Cut"); - char *copy_label = batch ? g_strdup_printf("Copy %u Entries", action_entries->len) : g_strdup("Copy"); - char *delete_label = batch ? g_strdup_printf("Delete %u Entries", action_entries->len) : g_strdup("Delete"); - GdkRectangle rectangle = { - .x = (int)x, - .y = (int)y, - .width = 1, - .height = 1, - }; - - gtk_popover_menu_set_menu_model(GTK_POPOVER_MENU(popover), G_MENU_MODEL(menu)); - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "new-file", "New File", context, FILE_TREE_MENU_NEW_FILE, item); - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "new-folder", "New Folder", context, FILE_TREE_MENU_NEW_FOLDER, item); - if (!root) { - if (!batch) { - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "rename", "Rename", context, FILE_TREE_MENU_RENAME, item); - } - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "cut", cut_label, context, FILE_TREE_MENU_CUT, item); - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "copy", copy_label, context, FILE_TREE_MENU_COPY, item); - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "delete", delete_label, context, FILE_TREE_MENU_DELETE, item); - } - if (file_tree_context_has_clipboard(context)) { - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "paste", "Paste", context, FILE_TREE_MENU_PASTE, item); - } - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "reveal", "Reveal in Files", context, FILE_TREE_MENU_REVEAL, item); - add_file_tree_menu_button(GTK_POPOVER_MENU(popover), "refresh", "Refresh", context, FILE_TREE_MENU_REFRESH, item); - gtk_widget_set_parent(popover, context->root); - gtk_popover_set_has_arrow(GTK_POPOVER(popover), FALSE); - gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rectangle); - g_signal_connect(popover, "closed", G_CALLBACK(gtk_widget_unparent), NULL); - gtk_popover_popup(GTK_POPOVER(popover)); - g_free(cut_label); - g_free(copy_label); - g_free(delete_label); - g_ptr_array_unref(action_entries); - g_object_unref(menu); -} - -static void show_file_tree_root_menu(FileTreeContext *context, double x, double y) { - if (context->root_path == NULL) { - return; - } - - KosmosFileTreeItem item = { - .name = context->root_name == NULL ? context->root_path : context->root_name, - .path = context->root_path, - .kind = "directory", - .children = NULL, - }; - - show_file_tree_menu(context, &item, x, y); -} - -static void file_tree_context_menu(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)n_press; - - FileTreeContext *context = user_data; - if (context->list == NULL) { - return; - } - - GtkWidget *source = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); - graphene_point_t list_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - graphene_point_t root_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - - if (!gtk_widget_compute_point(source, context->list, &source_point, &list_point) || - !gtk_widget_compute_point(source, context->root, &source_point, &root_point)) { - return; - } - - GtkWidget *picked = gtk_widget_pick(context->list, list_point.x, list_point.y, GTK_PICK_DEFAULT); - GtkTreeListRow *tree_row = tree_row_for_picked_widget(picked); - if (tree_row == NULL) { - show_file_tree_root_menu(context, root_point.x, root_point.y); - return; - } - - KosmosFileTreeItem *item = KOSMOS_FILE_TREE_ITEM(gtk_tree_list_row_get_item(tree_row)); - if (!kosmos_file_tree_item_is_message(item)) { - gboolean selected = file_tree_context_path_is_selected(context, item->path); - file_tree_context_set_focused_path(context, item->path); - if (!selected) { - file_tree_context_select_focused_path(context); - } - show_file_tree_menu(context, item, root_point.x, root_point.y); - } - - g_object_unref(tree_row); -} - -static void unbind_list_item(GtkSignalListItemFactory *factory, GtkListItem *list_item, gpointer user_data) { - (void)factory; - (void)user_data; - - GtkWidget *hit_row = gtk_list_item_get_child(list_item); - if (hit_row != NULL) { - GtkWidget *entry = g_object_get_data(G_OBJECT(hit_row), "entry-editor"); - - g_object_set_data(G_OBJECT(hit_row), "tree-row", NULL); - g_object_set_data(G_OBJECT(hit_row), "file-tree-context", NULL); - g_object_set_data(G_OBJECT(hit_row), "file-tree-item", NULL); - gtk_widget_remove_css_class(hit_row, "file-tree-drop-target"); - - if (entry != NULL) { - g_object_set_data(G_OBJECT(entry), "file-tree-context", NULL); - g_object_set_data(G_OBJECT(entry), "file-tree-item", NULL); - } - } -} - -static GtkWidget *create_file_tree_list(FileTreeContext *context, JsonObject *root) { - GListStore *root_store = g_list_store_new(KOSMOS_TYPE_FILE_TREE_ITEM); - KosmosFileTreeItem *root_item = file_tree_item_from_entry(root); - g_list_store_append(root_store, root_item); - file_tree_context_rebuild_monitors(context, root_item); - g_object_unref(root_item); - - GtkTreeListModel *tree_model = gtk_tree_list_model_new( - G_LIST_MODEL(root_store), - FALSE, - FALSE, - create_child_model, - NULL, - NULL - ); - GtkTreeListRow *root_row = g_list_model_get_item(G_LIST_MODEL(tree_model), 0); - if (root_row != NULL) { - gtk_tree_list_row_set_expanded(root_row, TRUE); - g_object_unref(root_row); - } - file_tree_context_restore_expanded(context, tree_model); - context->tree_model = g_object_ref(tree_model); - - GtkListItemFactory *factory = gtk_signal_list_item_factory_new(); - g_signal_connect(factory, "setup", G_CALLBACK(setup_list_item), NULL); - g_signal_connect(factory, "bind", G_CALLBACK(bind_list_item), context); - g_signal_connect(factory, "unbind", G_CALLBACK(unbind_list_item), NULL); - - GtkMultiSelection *selection = gtk_multi_selection_new(G_LIST_MODEL(tree_model)); - - guint focused_position = 0; - if (file_tree_model_index_for_path(tree_model, context->focused_path, &focused_position)) { - gtk_selection_model_select_item(GTK_SELECTION_MODEL(selection), focused_position, TRUE); - } - - GtkWidget *list = gtk_list_view_new(GTK_SELECTION_MODEL(selection), factory); - GtkGesture *click = gtk_gesture_click_new(); - GtkGesture *context_menu = gtk_gesture_click_new(); - - gtk_widget_set_margin_bottom(list, 8); - gtk_widget_set_vexpand(list, TRUE); - gtk_widget_set_hexpand(list, TRUE); - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); - g_signal_connect(click, "pressed", G_CALLBACK(file_tree_clicked), context); - gtk_widget_add_controller(list, GTK_EVENT_CONTROLLER(click)); - - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(context_menu), GDK_BUTTON_SECONDARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(context_menu), GTK_PHASE_CAPTURE); - g_signal_connect(context_menu, "pressed", G_CALLBACK(file_tree_context_menu), context); - gtk_widget_add_controller(list, GTK_EVENT_CONTROLLER(context_menu)); - - return list; -} - -static GtkWidget *create_file_tree_root(FileTreeContext *context) { - GtkWidget *root_widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *notice_label = gtk_label_new(NULL); - GtkWidget *scrolled = gtk_scrolled_window_new(); - - context->root = root_widget; - context->notice_label = notice_label; - context->scrolled = scrolled; - gtk_widget_set_hexpand(root_widget, TRUE); - gtk_widget_set_vexpand(root_widget, TRUE); - file_tree_install_css(root_widget); - - gtk_label_set_xalign(GTK_LABEL(notice_label), 0.0f); - gtk_label_set_wrap(GTK_LABEL(notice_label), TRUE); - gtk_widget_set_margin_top(notice_label, 8); - gtk_widget_set_margin_bottom(notice_label, 4); - gtk_widget_set_margin_start(notice_label, 10); - gtk_widget_set_margin_end(notice_label, 10); - gtk_widget_set_visible(notice_label, FALSE); - - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); - gtk_widget_set_hexpand(scrolled, TRUE); - gtk_widget_set_vexpand(scrolled, TRUE); - gtk_box_append(GTK_BOX(root_widget), notice_label); - gtk_box_append(GTK_BOX(root_widget), scrolled); - g_object_set_data_full(G_OBJECT(root_widget), "file-tree-context", context, (GDestroyNotify)file_tree_context_free); - - return root_widget; -} - -GtkWidget *kosmos_file_tree_tab_create(KosmosIpcClient *ipc_client, guint64 workspace_id) { - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(ipc_client), NULL); - - FileTreeContext *context = g_new0(FileTreeContext, 1); - context->ipc_client = g_object_ref(ipc_client); - context->workspace_id = workspace_id; - GtkWidget *view = create_file_tree_root(context); - file_tree_context_refresh(context); - - return view; -} diff --git a/desktop/src/tabs/kosmos_file_tree_tab.h b/desktop/src/tabs/kosmos_file_tree_tab.h deleted file mode 100644 index 1db32d6e..00000000 --- a/desktop/src/tabs/kosmos_file_tree_tab.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include - -#include "ipc/kosmos_ipc_client.h" - -G_BEGIN_DECLS - -GtkWidget *kosmos_file_tree_tab_create(KosmosIpcClient *ipc_client, guint64 workspace_id); - -G_END_DECLS diff --git a/desktop/src/ui/kosmos_main_window.c b/desktop/src/ui/kosmos_main_window.c deleted file mode 100644 index 862cf080..00000000 --- a/desktop/src/ui/kosmos_main_window.c +++ /dev/null @@ -1,255 +0,0 @@ -#include "ui/kosmos_main_window_private.h" - -G_DEFINE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, GTK_TYPE_APPLICATION_WINDOW) - -GtkWidget *kosmos_main_window_create_label(const char *text, const char *css_class) { - GtkWidget *label = gtk_label_new(text); - gtk_label_set_wrap(GTK_LABEL(label), TRUE); - gtk_label_set_xalign(GTK_LABEL(label), 0.0f); - - if (css_class != NULL) { - gtk_widget_add_css_class(label, css_class); - } - - return label; -} - -void kosmos_main_window_clear_content_area(KosmosMainWindow *self) { - self->suppress_tab_detach = TRUE; - - if (self->pane_views != NULL) { - g_hash_table_remove_all(self->pane_views); - } - g_clear_pointer(&self->layout_signature, g_free); - - if (self->staged_content_area != NULL) { - gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); - self->staged_content_area = NULL; - } - self->pending_layout_applies = 0; - self->hiding_layout_apply = FALSE; - self->has_rendered_workspace = FALSE; - gtk_widget_set_opacity(self->content_area, 1.0); - - GtkWidget *child = gtk_widget_get_first_child(self->content_area); - - while (child != NULL) { - GtkWidget *next = gtk_widget_get_next_sibling(child); - gtk_box_remove(GTK_BOX(self->content_area), child); - child = next; - } - - self->suppress_tab_detach = FALSE; -} - -void kosmos_main_window_set_uint64_data(GObject *object, const char *key, guint64 value) { - guint64 *stored_value = g_new(guint64, 1); - *stored_value = value; - g_object_set_data_full(object, key, stored_value, g_free); -} - -gboolean kosmos_main_window_get_uint64_data(GObject *object, const char *key, guint64 *value) { - guint64 *stored_value = g_object_get_data(object, key); - - if (stored_value == NULL) { - return FALSE; - } - - *value = *stored_value; - return TRUE; -} - -void kosmos_main_window_register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view) { - guint64 *key = g_new(guint64, 1); - *key = pane_id; - g_hash_table_replace(self->pane_views, key, view); -} - -AdwTabView *kosmos_main_window_pane_view_for(KosmosMainWindow *self, guint64 pane_id) { - return g_hash_table_lookup(self->pane_views, &pane_id); -} - -gboolean kosmos_json_get_uint_member(JsonObject *object, const char *name, guint64 *value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { - return FALSE; - } - - gint64 signed_value = json_node_get_int(node); - if (signed_value < 0) { - return FALSE; - } - - *value = (guint64)signed_value; - return TRUE; -} - -gboolean kosmos_json_get_string_member(JsonObject *object, const char *name, const char **value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node) || json_node_get_value_type(node) != G_TYPE_STRING) { - return FALSE; - } - - *value = json_node_get_string(node); - return TRUE; -} - -gboolean kosmos_json_get_double_member(JsonObject *object, const char *name, double *value) { - JsonNode *node = json_object_get_member(object, name); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { - return FALSE; - } - - *value = json_node_get_double(node); - return TRUE; -} - -JsonObject *kosmos_json_get_object_member(JsonObject *object, const char *name) { - JsonNode *node = json_object_get_member(object, name); - - if (node == NULL || !JSON_NODE_HOLDS_OBJECT(node)) { - return NULL; - } - - return json_node_get_object(node); -} - -JsonArray *kosmos_json_get_array_member(JsonObject *object, const char *name) { - JsonNode *node = json_object_get_member(object, name); - - if (node == NULL || !JSON_NODE_HOLDS_ARRAY(node)) { - return NULL; - } - - return json_node_get_array(node); -} - -void kosmos_main_window_set_status(KosmosMainWindow *self, const char *status) { - kosmos_main_window_clear_content_area(self); - - if (status == NULL || status[0] == '\0') { - return; - } - - GtkWidget *label = kosmos_main_window_create_label(status, "dim-label"); - gtk_widget_set_halign(label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label, GTK_ALIGN_CENTER); - gtk_widget_set_vexpand(label, TRUE); - gtk_box_append(GTK_BOX(self->content_area), label); -} - -void kosmos_main_window_set_error_status(KosmosMainWindow *self, const char *prefix, GError *error) { - char *message = g_strdup_printf("%s: %s", prefix, error->message); - kosmos_main_window_set_status(self, message); - g_free(message); -} - -gboolean kosmos_main_window_ensure_connected(KosmosMainWindow *self) { - GError *error = NULL; - - if (kosmos_ipc_client_connect(self->ipc_client, NULL, &error)) { - return TRUE; - } - - kosmos_main_window_set_status(self, "Start kosmos-server with ./scripts/run.sh to load workspaces."); - kosmos_workspace_switcher_clear(self); - g_clear_error(&error); - - return FALSE; -} - -void kosmos_main_window_apply_server_state_or_show_error( - KosmosMainWindow *self, - JsonNode *state, - GError *error, - const char *error_prefix -) { - if (error != NULL) { - kosmos_main_window_set_error_status(self, error_prefix, error); - return; - } - - kosmos_main_window_apply_workspace_state(self, state); -} - -static GtkWidget *create_header(KosmosMainWindow *self) { - GtkWidget *header = gtk_header_bar_new(); - gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), kosmos_workspace_switcher_create(self)); - - return header; -} - -static void kosmos_main_window_init(KosmosMainWindow *self) { - self->workspace_buttons = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - self->pane_views = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - kosmos_pane_dnd_install_css(GTK_WIDGET(self)); - - gtk_window_set_title(GTK_WINDOW(self), "Kosmos"); - gtk_window_set_default_size(GTK_WINDOW(self), 1000, 700); - - GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_window_set_child(GTK_WINDOW(self), root); - gtk_window_set_titlebar(GTK_WINDOW(self), create_header(self)); - - self->content_overlay = gtk_overlay_new(); - gtk_widget_set_hexpand(self->content_overlay, TRUE); - gtk_widget_set_vexpand(self->content_overlay, TRUE); - - self->content_area = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_hexpand(self->content_area, TRUE); - gtk_widget_set_vexpand(self->content_area, TRUE); - gtk_overlay_set_child(GTK_OVERLAY(self->content_overlay), self->content_area); - gtk_box_append(GTK_BOX(root), self->content_overlay); -} - -static void kosmos_main_window_finalize(GObject *object) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(object); - - kosmos_pane_dnd_clear_detached_tab_transfer(self); - g_clear_object(&self->ipc_client); - g_clear_pointer(&self->workspace_buttons, g_hash_table_unref); - g_clear_pointer(&self->pane_views, g_hash_table_unref); - g_clear_pointer(&self->layout_signature, g_free); - - G_OBJECT_CLASS(kosmos_main_window_parent_class)->finalize(object); -} - -static void kosmos_main_window_class_init(KosmosMainWindowClass *klass) { - GObjectClass *object_class = G_OBJECT_CLASS(klass); - object_class->finalize = kosmos_main_window_finalize; -} - -GtkWidget *kosmos_main_window_new(GtkApplication *application, KosmosIpcClient *ipc_client) { - g_return_val_if_fail(GTK_IS_APPLICATION(application), NULL); - g_return_val_if_fail(KOSMOS_IS_IPC_CLIENT(ipc_client), NULL); - - KosmosMainWindow *self = g_object_new( - KOSMOS_TYPE_MAIN_WINDOW, - "application", application, - NULL - ); - - self->ipc_client = g_object_ref(ipc_client); - - return GTK_WIDGET(self); -} - -void kosmos_main_window_refresh_workspace_state(KosmosMainWindow *self) { - g_return_if_fail(KOSMOS_IS_MAIN_WINDOW(self)); - - if (!kosmos_main_window_ensure_connected(self)) { - kosmos_workspace_switcher_ensure_add_button(self, FALSE); - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - if (!kosmos_ipc_client_list_workspaces(self->ipc_client, &state, NULL, &error)) { - kosmos_main_window_set_error_status(self, "Failed to load workspaces", error); - g_clear_error(&error); - return; - } - - kosmos_main_window_apply_workspace_state(self, state); - json_node_unref(state); -} diff --git a/desktop/src/ui/kosmos_main_window.h b/desktop/src/ui/kosmos_main_window.h deleted file mode 100644 index d16bd600..00000000 --- a/desktop/src/ui/kosmos_main_window.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include - -#include "ipc/kosmos_ipc_client.h" - -G_BEGIN_DECLS - -#define KOSMOS_TYPE_MAIN_WINDOW (kosmos_main_window_get_type()) - -G_DECLARE_FINAL_TYPE(KosmosMainWindow, kosmos_main_window, KOSMOS, MAIN_WINDOW, GtkApplicationWindow) - -GtkWidget *kosmos_main_window_new(GtkApplication *application, KosmosIpcClient *ipc_client); -void kosmos_main_window_refresh_workspace_state(KosmosMainWindow *self); - -G_END_DECLS diff --git a/desktop/src/ui/kosmos_main_window_private.h b/desktop/src/ui/kosmos_main_window_private.h deleted file mode 100644 index 0bb4f7c4..00000000 --- a/desktop/src/ui/kosmos_main_window_private.h +++ /dev/null @@ -1,112 +0,0 @@ -#pragma once - -#include -#include - -#include "ui/kosmos_main_window.h" - -#define KOSMOS_MIN_PANE_WIDTH 220 -#define KOSMOS_MIN_PANE_HEIGHT 160 - -struct _KosmosMainWindow { - GtkApplicationWindow parent_instance; - KosmosIpcClient *ipc_client; - GtkWidget *workspace_switcher; - GtkWidget *add_workspace_button; - GtkWidget *content_overlay; - GtkWidget *content_area; - GtkWidget *staged_content_area; - GHashTable *workspace_buttons; - GHashTable *pane_views; - char *layout_signature; - guint64 rendered_workspace_id; - guint pending_layout_applies; - guint layout_apply_serial; - AdwTabView *detached_tab_sink; - GtkWindow *detached_tab_window; - gboolean splitting_detached_tab; - gboolean suppress_tab_detach; - gboolean applying_server_state; - gboolean has_rendered_workspace; - gboolean hiding_layout_apply; -}; - -typedef struct { - guint64 target_pane_id; - KosmosIpcSplitAxis axis; - gboolean new_pane_first; -} KosmosSplitDropZone; - -GtkWidget *kosmos_main_window_create_label(const char *text, const char *css_class); -void kosmos_main_window_clear_content_area(KosmosMainWindow *self); - -void kosmos_main_window_set_uint64_data(GObject *object, const char *key, guint64 value); -gboolean kosmos_main_window_get_uint64_data(GObject *object, const char *key, guint64 *value); - -void kosmos_main_window_register_pane_view(KosmosMainWindow *self, guint64 pane_id, AdwTabView *view); -AdwTabView *kosmos_main_window_pane_view_for(KosmosMainWindow *self, guint64 pane_id); - -gboolean kosmos_json_get_uint_member(JsonObject *object, const char *name, guint64 *value); -gboolean kosmos_json_get_string_member(JsonObject *object, const char *name, const char **value); -gboolean kosmos_json_get_double_member(JsonObject *object, const char *name, double *value); -JsonObject *kosmos_json_get_object_member(JsonObject *object, const char *name); -JsonArray *kosmos_json_get_array_member(JsonObject *object, const char *name); - -void kosmos_main_window_set_status(KosmosMainWindow *self, const char *status); -void kosmos_main_window_set_error_status(KosmosMainWindow *self, const char *prefix, GError *error); -gboolean kosmos_main_window_ensure_connected(KosmosMainWindow *self); -void kosmos_main_window_apply_server_state_or_show_error( - KosmosMainWindow *self, - JsonNode *state, - GError *error, - const char *error_prefix -); -void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode *state); - -GtkWidget *kosmos_workspace_switcher_create(KosmosMainWindow *self); -GtkWidget *kosmos_workspace_switcher_ensure_add_button(KosmosMainWindow *self, gboolean sensitive); -void kosmos_workspace_switcher_clear(KosmosMainWindow *self); - -char *kosmos_pane_layout_create_signature(JsonObject *workspace); -void kosmos_pane_layout_begin_hidden_apply(KosmosMainWindow *self); -void kosmos_pane_layout_finish_hidden_apply(KosmosMainWindow *self); -gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace); -void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace); - -GtkWidget *kosmos_tabbed_pane_create( - KosmosMainWindow *self, - JsonObject *pane, - guint64 workspace_id, - gboolean is_active_pane -); -gboolean kosmos_tabbed_pane_update_from_pane( - KosmosMainWindow *self, - JsonObject *pane, - guint64 workspace_id, - guint64 active_pane_id -); -gboolean kosmos_tabbed_pane_update_from_pane_view( - KosmosMainWindow *self, - AdwTabView *view, - JsonObject *pane, - guint64 workspace_id, - guint64 active_pane_id, - gboolean allow_append -); -void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view); - -void kosmos_pane_dnd_install_css(GtkWidget *widget); -void kosmos_pane_dnd_set_split_targets_enabled(KosmosMainWindow *self, gboolean enabled); -GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id); -AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data); -void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self); -void kosmos_pane_dnd_configure_single_tab( - KosmosMainWindow *self, - GtkWidget *tab_bar, - GtkWidget *new_tab_button, - AdwTabView *tab_view, - guint64 workspace_id, - guint64 pane_id, - int tab_count, - AdwTabPage *active_page -); diff --git a/desktop/src/ui/kosmos_pane_dnd.c b/desktop/src/ui/kosmos_pane_dnd.c deleted file mode 100644 index e0e57cb8..00000000 --- a/desktop/src/ui/kosmos_pane_dnd.c +++ /dev/null @@ -1,751 +0,0 @@ -#include "ui/kosmos_main_window_private.h" - -typedef struct { - KosmosMainWindow *window; - guint64 workspace_id; - guint64 pane_id; - guint64 target_pane_id; - guint64 tab_id; - KosmosIpcSplitAxis axis; - gboolean new_pane_first; -} DetachedTabSplit; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; - GtkWidget *source; - GtkWidget *excluded_widget; - GtkWidget *preview; - char *title; - guint64 workspace_id; - guint64 pane_id; - double start_x; - double start_y; - gboolean active; -} PaneDrag; - -static void show_pane_drag_preview(PaneDrag *drag); -static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y); - -void kosmos_pane_dnd_install_css(GtkWidget *widget) { - static gboolean installed = FALSE; - - if (installed) { - return; - } - - GtkCssProvider *provider = gtk_css_provider_new(); - gtk_css_provider_load_from_string( - provider, - ".split-drop-highlight {" - " background: rgba(53, 132, 228, 0.24);" - " border: 2px solid rgba(53, 132, 228, 0.72);" - " border-radius: 10px;" - "}" - ".pane-drag-preview {" - " background: rgba(98, 99, 107, 0.96);" - " border-radius: 9px;" - " padding: 8px 12px;" - " box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);" - "}" - ); - gtk_style_context_add_provider_for_display( - gtk_widget_get_display(widget), - GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION - ); - g_object_unref(provider); - installed = TRUE; -} - -static void detached_tab_split_free(DetachedTabSplit *split) { - g_object_unref(split->window); - g_free(split); -} - -void kosmos_pane_dnd_clear_detached_tab_transfer(KosmosMainWindow *self) { - self->splitting_detached_tab = FALSE; - - if (self->detached_tab_window != NULL) { - gtk_window_destroy(self->detached_tab_window); - } - - g_clear_object(&self->detached_tab_window); - g_clear_object(&self->detached_tab_sink); -} - -static void request_split_tab( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - guint64 tab_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_split_tab( - self->ipc_client, - workspace_id, - pane_id, - target_pane_id, - tab_id, - axis, - new_pane_first, - &state, - NULL, - &error - ); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to split tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static void request_move_pane( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_move_pane( - self->ipc_client, - workspace_id, - pane_id, - target_pane_id, - axis, - new_pane_first, - &state, - NULL, - &error - ); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to move pane"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static gboolean finish_detached_tab_split(gpointer user_data) { - DetachedTabSplit *split = user_data; - KosmosMainWindow *self = split->window; - - request_split_tab( - self, - split->workspace_id, - split->pane_id, - split->target_pane_id, - split->tab_id, - split->axis, - split->new_pane_first - ); - - kosmos_pane_dnd_clear_detached_tab_transfer(self); - return G_SOURCE_REMOVE; -} - -static void split_attached_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - (void)position; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - if (!ADW_IS_TAB_VIEW(view) || !ADW_IS_TAB_PAGE(page)) { - return; - } - - gboolean is_split_target = g_object_get_data(G_OBJECT(view), "split-drop-target") != NULL; - - if (!self->splitting_detached_tab && !is_split_target) { - return; - } - - self->splitting_detached_tab = FALSE; - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { - kosmos_main_window_refresh_workspace_state(self); - return; - } - - guint64 target_pane_id = pane_id; - kosmos_main_window_get_uint64_data(G_OBJECT(view), "target-pane-id", &target_pane_id); - - gpointer axis_data = g_object_get_data(G_OBJECT(view), "split-axis"); - KosmosIpcSplitAxis axis = axis_data == NULL - ? KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL - : (KosmosIpcSplitAxis)(GPOINTER_TO_INT(axis_data) - 1); - - gpointer new_pane_first_data = g_object_get_data(G_OBJECT(view), "split-new-pane-first"); - gboolean new_pane_first = new_pane_first_data == NULL - ? FALSE - : GPOINTER_TO_INT(new_pane_first_data) - 1; - - DetachedTabSplit *split = g_new(DetachedTabSplit, 1); - split->window = g_object_ref(self); - split->workspace_id = workspace_id; - split->pane_id = pane_id; - split->target_pane_id = target_pane_id; - split->tab_id = tab_id; - split->axis = axis; - split->new_pane_first = new_pane_first; - g_timeout_add_full(G_PRIORITY_DEFAULT, 16, finish_detached_tab_split, split, (GDestroyNotify)detached_tab_split_free); -} - -AdwTabView *kosmos_pane_dnd_create_split_sink_for_detached_tab(AdwTabView *view, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - kosmos_tabbed_pane_clear_pending_activation(view); - kosmos_pane_dnd_clear_detached_tab_transfer(self); - - GtkWidget *window = adw_window_new(); - AdwTabView *sink = adw_tab_view_new(); - - self->splitting_detached_tab = TRUE; - self->detached_tab_window = GTK_WINDOW(g_object_ref_sink(window)); - self->detached_tab_sink = ADW_TAB_VIEW(g_object_ref_sink(sink)); - - g_object_set_data(G_OBJECT(sink), "split-drop-target", GINT_TO_POINTER(1)); - gtk_window_set_child(self->detached_tab_window, GTK_WIDGET(sink)); - gtk_window_set_transient_for(self->detached_tab_window, GTK_WINDOW(self)); - g_signal_connect(sink, "page-attached", G_CALLBACK(split_attached_tab), self); - gtk_window_present(self->detached_tab_window); - - return sink; -} - -static void set_split_drop_highlight(GtkDropControllerMotion *controller, gboolean highlighted) { - GtkWidget *highlight = g_object_get_data(G_OBJECT(controller), "split-drop-highlight"); - - if (highlight != NULL) { - gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); - } -} - -static void set_split_zone_highlight(GtkWidget *zone, gboolean highlighted) { - GtkWidget *highlight = g_object_get_data(G_OBJECT(zone), "split-drop-highlight"); - - if (highlight != NULL) { - gtk_widget_set_opacity(highlight, highlighted ? 1.0 : 0.0); - } -} - -static void set_pane_drag_highlight(KosmosMainWindow *self, GtkWidget *zone) { - GtkWidget *current_zone = g_object_get_data(G_OBJECT(self), "pane-drag-highlight-zone"); - - if (current_zone == zone) { - return; - } - - if (current_zone != NULL) { - set_split_zone_highlight(current_zone, FALSE); - } - - if (zone != NULL) { - set_split_zone_highlight(zone, TRUE); - } - - g_object_set_data(G_OBJECT(self), "pane-drag-highlight-zone", zone); -} - -static void set_split_drop_target_enabled(GtkWidget *target, gboolean enabled) { - GtkWidget *drop_receiver = g_object_get_data(G_OBJECT(target), "split-drop-receiver"); - GtkWidget *tab_view = g_object_get_data(G_OBJECT(target), "split-drop-tab-view"); - - gtk_widget_set_visible(target, enabled); - gtk_widget_set_can_target(target, enabled); - if (drop_receiver != NULL) { - gtk_widget_set_can_target(drop_receiver, enabled); - } - if (tab_view != NULL) { - gtk_widget_set_can_target(tab_view, enabled); - } -} - -static void set_split_targets_enabled_in(GtkWidget *widget, gboolean enabled) { - if (widget == NULL) { - return; - } - - if (g_object_get_data(G_OBJECT(widget), "split-drop-zone") != NULL) { - set_split_drop_target_enabled(widget, enabled); - } - - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - set_split_targets_enabled_in(child, enabled); - } -} - -void kosmos_pane_dnd_set_split_targets_enabled(KosmosMainWindow *self, gboolean enabled) { - if (!enabled) { - set_pane_drag_highlight(self, NULL); - } - - set_split_targets_enabled_in(self->content_area, enabled); - set_split_targets_enabled_in(self->staged_content_area, enabled); -} - -static gboolean split_zone_contains_point(GtkWidget *zone, GtkWidget *source, double source_x, double source_y) { - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)source_x, (float)source_y); - graphene_point_t zone_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - - if (!gtk_widget_compute_point(source, zone, &source_point, &zone_point)) { - return FALSE; - } - - return zone_point.x >= 0.0f && - zone_point.y >= 0.0f && - zone_point.x < gtk_widget_get_width(zone) && - zone_point.y < gtk_widget_get_height(zone); -} - -static gboolean find_split_zone_at( - GtkWidget *widget, - GtkWidget *source, - double source_x, - double source_y, - guint64 source_pane_id, - GtkWidget **zone_widget, - KosmosSplitDropZone *zone -) { - if (!gtk_widget_get_visible(widget)) { - return FALSE; - } - - KosmosSplitDropZone *candidate = g_object_get_data(G_OBJECT(widget), "split-drop-zone"); - if (candidate != NULL && - candidate->target_pane_id != source_pane_id && - split_zone_contains_point(widget, source, source_x, source_y)) { - *zone_widget = widget; - *zone = *candidate; - return TRUE; - } - - for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - if (find_split_zone_at(child, source, source_x, source_y, source_pane_id, zone_widget, zone)) { - return TRUE; - } - } - - return FALSE; -} - -static void pane_drag_begin(GtkGestureDrag *gesture, double start_x, double start_y, gpointer user_data) { - PaneDrag *drag = user_data; - - drag->active = FALSE; - if (!pane_drag_start_is_valid(drag, start_x, start_y)) { - gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED); - return; - } - - drag->start_x = start_x; - drag->start_y = start_y; - set_pane_drag_highlight(drag->window, NULL); -} - -static gboolean pane_drag_find_zone(PaneDrag *drag, double x, double y, GtkWidget **zone_widget, KosmosSplitDropZone *zone) { - return find_split_zone_at( - drag->window->content_area, - drag->source, - x, - y, - drag->pane_id, - zone_widget, - zone - ); -} - -static gboolean picked_widget_is_or_contains(GtkWidget *picked, GtkWidget *widget) { - if (widget == NULL || picked == NULL) { - return FALSE; - } - - return picked == widget || gtk_widget_is_ancestor(picked, widget); -} - -static gboolean pane_drag_starts_on_action_area(PaneDrag *drag, double x, double y) { - if (drag->excluded_widget == NULL) { - return FALSE; - } - - graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); - graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point) && x >= source_point.x - 8.0) { - return TRUE; - } - - return split_zone_contains_point(drag->excluded_widget, drag->source, x, y); -} - -static gboolean pane_drag_starts_in_tab_region(PaneDrag *drag, double x, double y) { - if (x < 0.0 || y < 0.0 || x >= gtk_widget_get_width(drag->source) || y >= gtk_widget_get_height(drag->source)) { - return FALSE; - } - - double action_start = gtk_widget_get_width(drag->source); - if (drag->excluded_widget != NULL) { - graphene_point_t action_origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); - graphene_point_t source_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (gtk_widget_compute_point(drag->excluded_widget, drag->source, &action_origin, &source_point)) { - action_start = source_point.x; - } - } - - double tab_region_end = MIN(action_start - 8.0, 260.0); - return x <= tab_region_end; -} - -static gboolean pane_drag_start_is_valid(PaneDrag *drag, double x, double y) { - GtkWidget *picked = gtk_widget_pick(drag->source, x, y, GTK_PICK_DEFAULT); - - if (picked == NULL || picked == drag->source) { - return FALSE; - } - - if (picked_widget_is_or_contains(picked, drag->excluded_widget) || pane_drag_starts_on_action_area(drag, x, y)) { - return FALSE; - } - - return pane_drag_starts_in_tab_region(drag, x, y); -} - -static gboolean pane_drag_offset_is_significant(double offset_x, double offset_y) { - return offset_x * offset_x + offset_y * offset_y >= 64.0; -} - -static GtkWidget *create_pane_drag_preview(const char *title) { - GtkWidget *preview = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); - GtkWidget *label = gtk_label_new(title == NULL || title[0] == '\0' ? "Blank" : title); - GtkWidget *close_icon = gtk_image_new_from_icon_name("window-close-symbolic"); - - gtk_widget_add_css_class(preview, "pane-drag-preview"); - gtk_widget_set_can_target(preview, FALSE); - gtk_widget_set_halign(preview, GTK_ALIGN_START); - gtk_widget_set_valign(preview, GTK_ALIGN_START); - gtk_widget_set_size_request(preview, 170, -1); - gtk_label_set_xalign(GTK_LABEL(label), 0.5f); - gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_can_target(label, FALSE); - gtk_widget_set_can_target(close_icon, FALSE); - - gtk_box_append(GTK_BOX(preview), label); - gtk_box_append(GTK_BOX(preview), close_icon); - - return preview; -} - -static void set_pane_drag_preview_position(PaneDrag *drag, double x, double y) { - if (drag->preview == NULL) { - return; - } - - graphene_point_t source_point = GRAPHENE_POINT_INIT((float)x, (float)y); - graphene_point_t overlay_point = GRAPHENE_POINT_INIT(0.0f, 0.0f); - if (!gtk_widget_compute_point(drag->source, drag->window->content_overlay, &source_point, &overlay_point)) { - return; - } - - gtk_widget_set_margin_start(drag->preview, MAX(0, (int)overlay_point.x - 85)); - gtk_widget_set_margin_top(drag->preview, MAX(0, (int)overlay_point.y - 18)); -} - -static void show_pane_drag_preview(PaneDrag *drag) { - if (drag->preview != NULL) { - return; - } - - drag->preview = create_pane_drag_preview(drag->title); - gtk_overlay_add_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); - set_pane_drag_preview_position(drag, drag->start_x, drag->start_y); -} - -static void clear_pane_drag_preview(PaneDrag *drag) { - if (drag->preview == NULL) { - return; - } - - gtk_overlay_remove_overlay(GTK_OVERLAY(drag->window->content_overlay), drag->preview); - drag->preview = NULL; -} - -static void pane_drag_update(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { - PaneDrag *drag = user_data; - GtkWidget *zone_widget = NULL; - KosmosSplitDropZone zone = {0}; - double x = drag->start_x + offset_x; - double y = drag->start_y + offset_y; - - if (!drag->active) { - if (!pane_drag_offset_is_significant(offset_x, offset_y)) { - return; - } - - drag->active = TRUE; - kosmos_tabbed_pane_clear_pending_activation(drag->view); - g_object_set_data(G_OBJECT(drag->view), "tab-press-active", NULL); - kosmos_pane_dnd_set_split_targets_enabled(drag->window, TRUE); - show_pane_drag_preview(drag); - gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); - } - - set_pane_drag_preview_position(drag, x, y); - if (pane_drag_find_zone(drag, x, y, &zone_widget, &zone)) { - set_pane_drag_highlight(drag->window, zone_widget); - } else { - set_pane_drag_highlight(drag->window, NULL); - } -} - -static void pane_drag_end(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer user_data) { - (void)gesture; - - PaneDrag *drag = user_data; - GtkWidget *zone_widget = NULL; - KosmosSplitDropZone zone = {0}; - gboolean has_zone = pane_drag_find_zone(drag, drag->start_x + offset_x, drag->start_y + offset_y, &zone_widget, &zone); - - if (!drag->active) { - return; - } - - drag->active = FALSE; - set_pane_drag_highlight(drag->window, NULL); - clear_pane_drag_preview(drag); - kosmos_pane_dnd_set_split_targets_enabled(drag->window, FALSE); - if (!has_zone) { - return; - } - - request_move_pane( - drag->window, - drag->workspace_id, - drag->pane_id, - zone.target_pane_id, - zone.axis, - zone.new_pane_first - ); -} - -static void pane_drag_free(PaneDrag *drag) { - clear_pane_drag_preview(drag); - g_free(drag->title); - g_free(drag); -} - -static void remove_single_tab_pane_drag(GtkWidget *widget) { - GtkEventController *controller = g_object_get_data(G_OBJECT(widget), "pane-drag-controller"); - - if (controller == NULL) { - return; - } - - g_object_set_data(G_OBJECT(widget), "pane-drag-controller", NULL); - gtk_widget_remove_controller(widget, controller); -} - -static void add_single_tab_pane_drag( - KosmosMainWindow *self, - GtkWidget *widget, - GtkWidget *excluded_widget, - AdwTabView *tab_view, - guint64 workspace_id, - guint64 pane_id, - const char *title -) { - GtkGesture *gesture = gtk_gesture_drag_new(); - PaneDrag *drag = g_new0(PaneDrag, 1); - - remove_single_tab_pane_drag(widget); - - drag->window = self; - drag->view = tab_view; - drag->source = widget; - drag->excluded_widget = excluded_widget; - drag->title = g_strdup(title); - drag->workspace_id = workspace_id; - drag->pane_id = pane_id; - - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); - g_object_set_data_full(G_OBJECT(gesture), "pane-drag", drag, (GDestroyNotify)pane_drag_free); - g_signal_connect(gesture, "drag-begin", G_CALLBACK(pane_drag_begin), drag); - g_signal_connect(gesture, "drag-update", G_CALLBACK(pane_drag_update), drag); - g_signal_connect(gesture, "drag-end", G_CALLBACK(pane_drag_end), drag); - gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(gesture)); - g_object_set_data(G_OBJECT(widget), "pane-drag-controller", gesture); -} - -void kosmos_pane_dnd_configure_single_tab( - KosmosMainWindow *self, - GtkWidget *tab_bar, - GtkWidget *new_tab_button, - AdwTabView *tab_view, - guint64 workspace_id, - guint64 pane_id, - int tab_count, - AdwTabPage *active_page -) { - if (tab_count == 1 && active_page != NULL) { - add_single_tab_pane_drag( - self, - tab_bar, - new_tab_button, - tab_view, - workspace_id, - pane_id, - adw_tab_page_get_title(active_page) - ); - return; - } - - remove_single_tab_pane_drag(tab_bar); -} - -static void show_split_drop_highlight(GtkDropControllerMotion *controller, double x, double y, gpointer user_data) { - (void)x; - (void)y; - (void)user_data; - - set_split_drop_highlight(controller, TRUE); -} - -static void hide_split_drop_highlight(GtkDropControllerMotion *controller, gpointer user_data) { - (void)user_data; - - set_split_drop_highlight(controller, FALSE); -} - -static GtkWidget *create_split_drop_highlight(void) { - GtkWidget *highlight = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_add_css_class(highlight, "split-drop-highlight"); - gtk_widget_set_can_target(highlight, FALSE); - gtk_widget_set_hexpand(highlight, TRUE); - gtk_widget_set_vexpand(highlight, TRUE); - gtk_widget_set_opacity(highlight, 0.0); - - return highlight; -} - -static GtkWidget *create_split_drop_target( - KosmosMainWindow *self, - guint64 target_pane_id, - KosmosIpcSplitAxis axis, - gboolean new_pane_first -) { - GtkWidget *target = gtk_overlay_new(); - GtkWidget *drop_receiver = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *highlight = create_split_drop_highlight(); - GtkEventController *motion = GTK_EVENT_CONTROLLER(gtk_drop_controller_motion_new()); - AdwTabBar *tab_bar = adw_tab_bar_new(); - AdwTabView *tab_view = adw_tab_view_new(); - KosmosSplitDropZone *zone = g_new(KosmosSplitDropZone, 1); - - adw_tab_bar_set_view(tab_bar, tab_view); - gtk_widget_set_visible(GTK_WIDGET(tab_bar), FALSE); - gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_opacity(GTK_WIDGET(tab_view), 0.01); - gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_bar)); - gtk_box_append(GTK_BOX(drop_receiver), GTK_WIDGET(tab_view)); - gtk_overlay_set_child(GTK_OVERLAY(target), drop_receiver); - gtk_overlay_add_overlay(GTK_OVERLAY(target), highlight); - - g_object_set_data(G_OBJECT(tab_view), "split-drop-target", GINT_TO_POINTER(1)); - kosmos_main_window_set_uint64_data(G_OBJECT(tab_view), "target-pane-id", target_pane_id); - g_object_set_data(G_OBJECT(tab_view), "split-axis", GINT_TO_POINTER(axis + 1)); - g_object_set_data(G_OBJECT(tab_view), "split-new-pane-first", GINT_TO_POINTER((new_pane_first ? 1 : 0) + 1)); - g_signal_connect(tab_view, "page-attached", G_CALLBACK(split_attached_tab), self); - - zone->target_pane_id = target_pane_id; - zone->axis = axis; - zone->new_pane_first = new_pane_first; - g_object_set_data_full(G_OBJECT(target), "split-drop-zone", zone, g_free); - g_object_set_data(G_OBJECT(target), "split-drop-receiver", drop_receiver); - g_object_set_data(G_OBJECT(target), "split-drop-tab-view", GTK_WIDGET(tab_view)); - g_object_set_data(G_OBJECT(target), "split-drop-highlight", highlight); - - g_object_set_data(G_OBJECT(motion), "split-drop-highlight", highlight); - g_signal_connect(motion, "enter", G_CALLBACK(show_split_drop_highlight), NULL); - g_signal_connect(motion, "motion", G_CALLBACK(show_split_drop_highlight), NULL); - g_signal_connect(motion, "leave", G_CALLBACK(hide_split_drop_highlight), NULL); - gtk_widget_add_controller(target, motion); - set_split_drop_target_enabled(target, FALSE); - - return target; -} - -static void add_split_drop_zone( - GtkOverlay *overlay, - GtkWidget *target, - GtkAlign halign, - GtkAlign valign, - int width, - int height -) { - gtk_widget_set_halign(target, halign); - gtk_widget_set_valign(target, valign); - gtk_widget_set_size_request(target, width, height); - gtk_overlay_add_overlay(overlay, target); - set_split_drop_target_enabled(target, FALSE); -} - -GtkWidget *kosmos_pane_dnd_create_split_overlay(KosmosMainWindow *self, AdwTabView *tab_view, guint64 pane_id) { - GtkWidget *overlay = gtk_overlay_new(); - gtk_widget_set_hexpand(overlay, TRUE); - gtk_widget_set_vexpand(overlay, TRUE); - gtk_overlay_set_child(GTK_OVERLAY(overlay), GTK_WIDGET(tab_view)); - - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, TRUE), - GTK_ALIGN_START, - GTK_ALIGN_FILL, - 96, - -1 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_HORIZONTAL, FALSE), - GTK_ALIGN_END, - GTK_ALIGN_FILL, - 96, - -1 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, TRUE), - GTK_ALIGN_FILL, - GTK_ALIGN_START, - -1, - 96 - ); - add_split_drop_zone( - GTK_OVERLAY(overlay), - create_split_drop_target(self, pane_id, KOSMOS_IPC_SPLIT_AXIS_VERTICAL, FALSE), - GTK_ALIGN_FILL, - GTK_ALIGN_END, - -1, - 96 - ); - - return overlay; -} diff --git a/desktop/src/ui/kosmos_pane_layout.c b/desktop/src/ui/kosmos_pane_layout.c deleted file mode 100644 index 19f1c441..00000000 --- a/desktop/src/ui/kosmos_pane_layout.c +++ /dev/null @@ -1,605 +0,0 @@ -#include "ui/kosmos_main_window_private.h" - -#define KOSMOS_PANED_RATIO_EPSILON 0.001 - -typedef struct { - KosmosMainWindow *window; - GtkOrientation orientation; - double ratio; - guint serial; - guint layout_apply_serial; - gboolean pending_layout_apply; -} PanedRatio; - -static void set_paned_ratio_after_layout(KosmosMainWindow *self, GtkWidget *paned, GtkOrientation orientation, double ratio); - -static GtkWidget *layout_content_area(KosmosMainWindow *self) { - return self->staged_content_area == NULL ? self->content_area : self->staged_content_area; -} - -static void reveal_hidden_layout_if_ready(KosmosMainWindow *self) { - if (!self->hiding_layout_apply || self->pending_layout_applies > 0) { - return; - } - - self->hiding_layout_apply = FALSE; - if (self->staged_content_area == NULL) { - return; - } - - GtkWidget *staged_content_area = g_object_ref(self->staged_content_area); - gtk_widget_set_opacity(staged_content_area, 1.0); - gtk_widget_set_can_target(staged_content_area, TRUE); - gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), staged_content_area); - gtk_overlay_set_child(GTK_OVERLAY(self->content_overlay), staged_content_area); - self->content_area = staged_content_area; - self->staged_content_area = NULL; - g_object_unref(staged_content_area); -} - -void kosmos_pane_layout_begin_hidden_apply(KosmosMainWindow *self) { - if (self->staged_content_area != NULL) { - gtk_overlay_remove_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); - self->staged_content_area = NULL; - } - - self->layout_apply_serial++; - self->pending_layout_applies = 0; - self->hiding_layout_apply = TRUE; - - self->staged_content_area = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_hexpand(self->staged_content_area, TRUE); - gtk_widget_set_vexpand(self->staged_content_area, TRUE); - gtk_widget_set_opacity(self->staged_content_area, 0.0); - gtk_widget_set_can_target(self->staged_content_area, FALSE); - gtk_overlay_add_overlay(GTK_OVERLAY(self->content_overlay), self->staged_content_area); - g_hash_table_remove_all(self->pane_views); -} - -void kosmos_pane_layout_finish_hidden_apply(KosmosMainWindow *self) { - reveal_hidden_layout_if_ready(self); -} - -static void finish_paned_ratio_apply(PanedRatio *ratio) { - KosmosMainWindow *self = ratio->window; - - if (!ratio->pending_layout_apply || ratio->layout_apply_serial != self->layout_apply_serial) { - return; - } - - ratio->pending_layout_apply = FALSE; - if (self->pending_layout_applies > 0) { - self->pending_layout_applies--; - } - reveal_hidden_layout_if_ready(self); -} - -static void paned_ratio_free(PanedRatio *ratio) { - finish_paned_ratio_apply(ratio); - g_object_unref(ratio->window); - g_free(ratio); -} - -static gboolean clear_applying_paned_ratio(gpointer user_data) { - GObject *object = user_data; - g_object_set_data(object, "applying-server-ratio", NULL); - g_object_unref(object); - return G_SOURCE_REMOVE; -} - -static gboolean get_double_data(GObject *object, const char *key, double *value) { - double *stored_value = g_object_get_data(object, key); - - if (stored_value == NULL) { - return FALSE; - } - - *value = *stored_value; - return TRUE; -} - -static void set_double_data(GObject *object, const char *key, double value) { - double *stored_value = g_new(double, 1); - *stored_value = value; - g_object_set_data_full(object, key, stored_value, g_free); -} - -static gboolean ratios_match(double first, double second) { - return ABS(first - second) <= KOSMOS_PANED_RATIO_EPSILON; -} - -static void remember_server_paned_ratio(GtkWidget *paned, guint64 workspace_id, guint64 split_id, double ratio) { - GObject *object = G_OBJECT(paned); - kosmos_main_window_set_uint64_data(object, "server-ratio-workspace-id", workspace_id); - kosmos_main_window_set_uint64_data(object, "server-ratio-split-id", split_id); - set_double_data(object, "server-ratio", ratio); -} - -static gboolean paned_already_has_server_ratio(GtkWidget *paned, guint64 workspace_id, guint64 split_id, double ratio) { - guint64 stored_workspace_id = 0; - guint64 stored_split_id = 0; - double stored_ratio = 0.0; - - return kosmos_main_window_get_uint64_data(G_OBJECT(paned), "server-ratio-workspace-id", &stored_workspace_id) && - kosmos_main_window_get_uint64_data(G_OBJECT(paned), "server-ratio-split-id", &stored_split_id) && - get_double_data(G_OBJECT(paned), "server-ratio", &stored_ratio) && - stored_workspace_id == workspace_id && - stored_split_id == split_id && - ratios_match(stored_ratio, ratio); -} - -static guint next_paned_ratio_serial(GtkWidget *paned) { - guint serial = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(paned), "server-ratio-serial")) + 1; - g_object_set_data(G_OBJECT(paned), "server-ratio-serial", GUINT_TO_POINTER(serial)); - return serial; -} - -static void request_split_resize(KosmosMainWindow *self, guint64 workspace_id, guint64 split_id, double ratio) { - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - kosmos_ipc_client_resize_pane_split( - self->ipc_client, - workspace_id, - split_id, - ratio, - NULL, - NULL, - &error - ); - - if (error != NULL) { - g_warning("Failed to resize pane split: %s", error->message); - g_clear_error(&error); - } -} - -static void paned_position_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { - (void)pspec; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - if (self->applying_server_state || g_object_get_data(object, "applying-server-ratio") != NULL) { - return; - } - - GtkWidget *widget = GTK_WIDGET(object); - GtkOrientation orientation = gtk_orientable_get_orientation(GTK_ORIENTABLE(widget)); - int size = orientation == GTK_ORIENTATION_HORIZONTAL - ? gtk_widget_get_width(widget) - : gtk_widget_get_height(widget); - if (size <= 1) { - return; - } - - guint64 workspace_id = 0; - guint64 split_id = 0; - if (!kosmos_main_window_get_uint64_data(object, "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(object, "split-id", &split_id)) { - return; - } - - double ratio = CLAMP((double)gtk_paned_get_position(GTK_PANED(widget)) / size, 0.05, 0.95); - if (paned_already_has_server_ratio(widget, workspace_id, split_id, ratio)) { - return; - } - - remember_server_paned_ratio(widget, workspace_id, split_id, ratio); - request_split_resize(self, workspace_id, split_id, ratio); -} - -static void configure_paned_split(KosmosMainWindow *self, GtkWidget *paned, guint64 workspace_id, guint64 split_id) { - kosmos_main_window_set_uint64_data(G_OBJECT(paned), "workspace-id", workspace_id); - kosmos_main_window_set_uint64_data(G_OBJECT(paned), "split-id", split_id); - - if (g_object_get_data(G_OBJECT(paned), "split-resize-handler-installed") != NULL) { - return; - } - - g_signal_connect(paned, "notify::position", G_CALLBACK(paned_position_changed), self); - g_object_set_data(G_OBJECT(paned), "split-resize-handler-installed", GINT_TO_POINTER(1)); -} - -static gboolean append_layout_signature(GString *signature, JsonObject *node) { - const char *type = NULL; - if (!kosmos_json_get_string_member(node, "type", &type)) { - return FALSE; - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = kosmos_json_get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { - return FALSE; - } - - g_string_append_printf(signature, "L:%" G_GUINT64_FORMAT, pane_id); - return TRUE; - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - guint64 split_id = 0; - JsonObject *first = kosmos_json_get_object_member(node, "first"); - JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_uint_member(node, "id", &split_id) || - !kosmos_json_get_string_member(node, "axis", &axis) || - first == NULL || - second == NULL) { - return FALSE; - } - - g_string_append_printf(signature, "S:%" G_GUINT64_FORMAT ":%s(", split_id, axis); - if (!append_layout_signature(signature, first)) { - return FALSE; - } - g_string_append_c(signature, ','); - if (!append_layout_signature(signature, second)) { - return FALSE; - } - g_string_append_c(signature, ')'); - return TRUE; - } - - return FALSE; -} - -char *kosmos_pane_layout_create_signature(JsonObject *workspace) { - JsonObject *root = kosmos_json_get_object_member(workspace, "root"); - guint64 workspace_id = 0; - if (root == NULL || !kosmos_json_get_uint_member(workspace, "id", &workspace_id)) { - return NULL; - } - - GString *signature = g_string_new(NULL); - g_string_append_printf(signature, "W:%" G_GUINT64_FORMAT ";", workspace_id); - if (!append_layout_signature(signature, root)) { - g_string_free(signature, TRUE); - return NULL; - } - - return g_string_free(signature, FALSE); -} - -static gboolean update_pane_node_in_place( - KosmosMainWindow *self, - GtkWidget *current, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id -) { - const char *type = NULL; - if (current == NULL || !kosmos_json_get_string_member(node, "type", &type)) { - return FALSE; - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = kosmos_json_get_object_member(node, "pane"); - return pane != NULL && kosmos_tabbed_pane_update_from_pane(self, pane, workspace_id, active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - guint64 split_id = 0; - double ratio = 0.5; - JsonObject *first = kosmos_json_get_object_member(node, "first"); - JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!GTK_IS_PANED(current) || - !kosmos_json_get_uint_member(node, "id", &split_id) || - !kosmos_json_get_string_member(node, "axis", &axis) || - first == NULL || - second == NULL) { - return FALSE; - } - - kosmos_json_get_double_member(node, "ratio", &ratio); - GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 - ? GTK_ORIENTATION_VERTICAL - : GTK_ORIENTATION_HORIZONTAL; - gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); - gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); - gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); - configure_paned_split(self, current, workspace_id, split_id); - set_paned_ratio_after_layout(self, current, orientation, ratio); - - return update_pane_node_in_place(self, gtk_paned_get_start_child(GTK_PANED(current)), first, workspace_id, active_pane_id) && - update_pane_node_in_place(self, gtk_paned_get_end_child(GTK_PANED(current)), second, workspace_id, active_pane_id); - } - - return FALSE; -} - -gboolean kosmos_pane_layout_update_active_workspace_in_place(KosmosMainWindow *self, JsonObject *workspace) { - guint64 workspace_id = 0; - guint64 active_pane_id = 0; - JsonObject *root = kosmos_json_get_object_member(workspace, "root"); - - if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || - !kosmos_json_get_uint_member(workspace, "activePaneId", &active_pane_id) || - root == NULL) { - return FALSE; - } - - GtkWidget *current_root = gtk_widget_get_first_child(layout_content_area(self)); - return update_pane_node_in_place(self, current_root, root, workspace_id, active_pane_id); -} - -static gboolean apply_paned_ratio(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer user_data) { - (void)frame_clock; - - PanedRatio *ratio = user_data; - guint current_serial = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(widget), "server-ratio-serial")); - if (current_serial != ratio->serial) { - finish_paned_ratio_apply(ratio); - return G_SOURCE_REMOVE; - } - - int size = ratio->orientation == GTK_ORIENTATION_HORIZONTAL - ? gtk_widget_get_width(widget) - : gtk_widget_get_height(widget); - - if (size <= 1) { - return G_SOURCE_CONTINUE; - } - - int position = (int)((size * ratio->ratio) + 0.5); - if (ABS(gtk_paned_get_position(GTK_PANED(widget)) - position) <= 1) { - g_object_set_data(G_OBJECT(widget), "applying-server-ratio", NULL); - finish_paned_ratio_apply(ratio); - return G_SOURCE_REMOVE; - } - - g_object_set_data(G_OBJECT(widget), "applying-server-ratio", GINT_TO_POINTER(1)); - gtk_paned_set_position(GTK_PANED(widget), position); - g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, - clear_applying_paned_ratio, - g_object_ref(widget), - NULL - ); - finish_paned_ratio_apply(ratio); - return G_SOURCE_REMOVE; -} - -static void set_paned_ratio_after_layout(KosmosMainWindow *self, GtkWidget *paned, GtkOrientation orientation, double ratio) { - guint64 workspace_id = 0; - guint64 split_id = 0; - ratio = CLAMP(ratio, 0.05, 0.95); - - if (kosmos_main_window_get_uint64_data(G_OBJECT(paned), "workspace-id", &workspace_id) && - kosmos_main_window_get_uint64_data(G_OBJECT(paned), "split-id", &split_id) && - paned_already_has_server_ratio(paned, workspace_id, split_id, ratio)) { - return; - } - - if (workspace_id != 0 && split_id != 0) { - remember_server_paned_ratio(paned, workspace_id, split_id, ratio); - } - - g_object_set_data(G_OBJECT(paned), "applying-server-ratio", GINT_TO_POINTER(1)); - - PanedRatio *ratio_data = g_new(PanedRatio, 1); - ratio_data->window = g_object_ref(self); - ratio_data->orientation = orientation; - ratio_data->ratio = ratio; - ratio_data->serial = next_paned_ratio_serial(paned); - ratio_data->layout_apply_serial = self->layout_apply_serial; - ratio_data->pending_layout_apply = self->hiding_layout_apply; - if (ratio_data->pending_layout_apply) { - self->pending_layout_applies++; - } - - gtk_widget_add_tick_callback(paned, apply_paned_ratio, ratio_data, (GDestroyNotify)paned_ratio_free); -} - -static GtkWidget *create_pane_node( - KosmosMainWindow *self, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path, - GHashTable *reusable_panes -); - -static GtkWidget *create_split_node( - KosmosMainWindow *self, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path, - GHashTable *reusable_panes -) { - const char *axis = NULL; - guint64 split_id = 0; - double ratio = 0.5; - JsonObject *first = kosmos_json_get_object_member(node, "first"); - JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_uint_member(node, "id", &split_id) || - !kosmos_json_get_string_member(node, "axis", &axis) || - first == NULL || - second == NULL) { - return kosmos_main_window_create_label("Invalid pane split.", "error"); - } - - kosmos_json_get_double_member(node, "ratio", &ratio); - - GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 - ? GTK_ORIENTATION_VERTICAL - : GTK_ORIENTATION_HORIZONTAL; - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - GtkWidget *paned = gtk_paned_new(orientation); - gtk_widget_set_hexpand(paned, TRUE); - gtk_widget_set_vexpand(paned, TRUE); - gtk_paned_set_shrink_start_child(GTK_PANED(paned), FALSE); - gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); - configure_paned_split(self, paned, workspace_id, split_id); - gtk_paned_set_start_child(GTK_PANED(paned), create_pane_node(self, first, workspace_id, active_pane_id, first_path, reusable_panes)); - gtk_paned_set_end_child(GTK_PANED(paned), create_pane_node(self, second, workspace_id, active_pane_id, second_path, reusable_panes)); - set_paned_ratio_after_layout(self, paned, orientation, ratio); - g_free(first_path); - g_free(second_path); - return paned; -} - -static GtkWidget *create_pane_node( - KosmosMainWindow *self, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path, - GHashTable *reusable_panes -) { - const char *type = NULL; - if (!kosmos_json_get_string_member(node, "type", &type)) { - return kosmos_main_window_create_label("Invalid pane node.", "error"); - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = kosmos_json_get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { - return kosmos_main_window_create_label("Invalid pane leaf.", "error"); - } - - GtkWidget *reusable_pane = reusable_panes == NULL ? NULL : g_hash_table_lookup(reusable_panes, &pane_id); - if (reusable_pane != NULL && gtk_widget_get_parent(reusable_pane) == NULL) { - AdwTabView *tab_view = g_object_get_data(G_OBJECT(reusable_pane), "tab-view"); - if (tab_view != NULL && kosmos_tabbed_pane_update_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { - return reusable_pane; - } - } - - return kosmos_tabbed_pane_create(self, pane, workspace_id, pane_id == active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - return create_split_node(self, node, workspace_id, active_pane_id, path, reusable_panes); - } - - return kosmos_main_window_create_label("Unsupported pane node.", "error"); -} - -static gboolean widget_is_pane_leaf(GtkWidget *widget, guint64 pane_id) { - guint64 widget_pane_id = 0; - - return kosmos_main_window_get_uint64_data(G_OBJECT(widget), "pane-id", &widget_pane_id) && widget_pane_id == pane_id; -} - -static GtkWidget *reconcile_pane_node( - KosmosMainWindow *self, - GtkWidget *current, - JsonObject *node, - guint64 workspace_id, - guint64 active_pane_id, - const char *path -) { - const char *type = NULL; - if (!kosmos_json_get_string_member(node, "type", &type)) { - return kosmos_main_window_create_label("Invalid pane node.", "error"); - } - - if (g_strcmp0(type, "leaf") == 0) { - JsonObject *pane = kosmos_json_get_object_member(node, "pane"); - guint64 pane_id = 0; - if (pane == NULL || !kosmos_json_get_uint_member(pane, "id", &pane_id)) { - return kosmos_main_window_create_label("Invalid pane leaf.", "error"); - } - - if (current != NULL && widget_is_pane_leaf(current, pane_id)) { - AdwTabView *tab_view = g_object_get_data(G_OBJECT(current), "tab-view"); - if (tab_view != NULL && kosmos_tabbed_pane_update_from_pane_view(self, tab_view, pane, workspace_id, active_pane_id, FALSE)) { - return current; - } - } - - return kosmos_tabbed_pane_create(self, pane, workspace_id, pane_id == active_pane_id); - } - - if (g_strcmp0(type, "split") == 0) { - const char *axis = NULL; - guint64 split_id = 0; - double ratio = 0.5; - JsonObject *first = kosmos_json_get_object_member(node, "first"); - JsonObject *second = kosmos_json_get_object_member(node, "second"); - if (!kosmos_json_get_uint_member(node, "id", &split_id) || - !kosmos_json_get_string_member(node, "axis", &axis) || - first == NULL || - second == NULL) { - return kosmos_main_window_create_label("Invalid pane split.", "error"); - } - - kosmos_json_get_double_member(node, "ratio", &ratio); - - GtkOrientation orientation = g_strcmp0(axis, "vertical") == 0 - ? GTK_ORIENTATION_VERTICAL - : GTK_ORIENTATION_HORIZONTAL; - char *first_path = g_strconcat(path, "/first", NULL); - char *second_path = g_strconcat(path, "/second", NULL); - - if (!GTK_IS_PANED(current)) { - GtkWidget *created = create_pane_node(self, node, workspace_id, active_pane_id, path, NULL); - g_free(first_path); - g_free(second_path); - return created; - } - - gtk_orientable_set_orientation(GTK_ORIENTABLE(current), orientation); - gtk_paned_set_shrink_start_child(GTK_PANED(current), FALSE); - gtk_paned_set_shrink_end_child(GTK_PANED(current), FALSE); - configure_paned_split(self, current, workspace_id, split_id); - - GtkWidget *old_start = gtk_paned_get_start_child(GTK_PANED(current)); - GtkWidget *new_start = reconcile_pane_node(self, old_start, first, workspace_id, active_pane_id, first_path); - if (new_start != old_start) { - gtk_paned_set_start_child(GTK_PANED(current), new_start); - } - - GtkWidget *old_end = gtk_paned_get_end_child(GTK_PANED(current)); - GtkWidget *new_end = reconcile_pane_node(self, old_end, second, workspace_id, active_pane_id, second_path); - if (new_end != old_end) { - gtk_paned_set_end_child(GTK_PANED(current), new_end); - } - - set_paned_ratio_after_layout(self, current, orientation, ratio); - g_free(first_path); - g_free(second_path); - return current; - } - - return kosmos_main_window_create_label("Unsupported pane node.", "error"); -} - -void kosmos_pane_layout_render_active_workspace(KosmosMainWindow *self, JsonObject *workspace) { - if (workspace == NULL) { - kosmos_main_window_clear_content_area(self); - return; - } - - guint64 workspace_id = 0; - guint64 active_pane_id = 0; - JsonObject *root = kosmos_json_get_object_member(workspace, "root"); - - if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || - !kosmos_json_get_uint_member(workspace, "activePaneId", &active_pane_id) || - root == NULL) { - kosmos_main_window_set_status(self, "Invalid workspace snapshot."); - return; - } - - GtkWidget *content_area = layout_content_area(self); - GtkWidget *current_root = gtk_widget_get_first_child(content_area); - if (current_root != NULL) { - g_hash_table_remove_all(self->pane_views); - GtkWidget *pane_tree = reconcile_pane_node(self, current_root, root, workspace_id, active_pane_id, "root"); - if (pane_tree != current_root) { - gtk_box_remove(GTK_BOX(content_area), current_root); - gtk_box_append(GTK_BOX(content_area), pane_tree); - } - return; - } - - GtkWidget *pane_tree = create_pane_node(self, root, workspace_id, active_pane_id, "root", NULL); - gtk_box_append(GTK_BOX(content_area), pane_tree); -} diff --git a/desktop/src/ui/kosmos_tabbed_pane.c b/desktop/src/ui/kosmos_tabbed_pane.c deleted file mode 100644 index 4a3902a4..00000000 --- a/desktop/src/ui/kosmos_tabbed_pane.c +++ /dev/null @@ -1,755 +0,0 @@ -#include "ui/kosmos_main_window_private.h" -#include "tabs/kosmos_blank_tab.h" -#include "tabs/kosmos_file_tree_tab.h" - -typedef struct { - guint64 workspace_id; - guint64 pane_id; - guint64 tab_id; -} PendingTabActivation; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; -} TabBarActivation; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; -} DeferredTabActivation; - -typedef struct { - KosmosMainWindow *window; - guint64 workspace_id; - guint64 pane_id; - guint64 tab_id; -} TabKindChangeRequest; - -typedef struct { - KosmosMainWindow *window; - AdwTabView *view; -} DeferredSplitTargetDisable; - -void kosmos_tabbed_pane_clear_pending_activation(AdwTabView *view) { - g_object_set_data(G_OBJECT(view), "pending-tab-activation", NULL); -} - -static void set_pending_tab_activation(AdwTabView *view, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { - PendingTabActivation *activation = g_new(PendingTabActivation, 1); - activation->workspace_id = workspace_id; - activation->pane_id = pane_id; - activation->tab_id = tab_id; - - g_object_set_data_full(G_OBJECT(view), "pending-tab-activation", activation, g_free); -} - -static void request_tab_activation(KosmosMainWindow *self, guint64 workspace_id, guint64 pane_id, guint64 tab_id) { - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_activate_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to activate tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static void tab_bar_pressed(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)gesture; - (void)n_press; - (void)x; - (void)y; - - TabBarActivation *activation = user_data; - g_object_set_data(G_OBJECT(activation->view), "tab-press-active", GINT_TO_POINTER(1)); -} - -static void deferred_tab_activation_free(DeferredTabActivation *activation) { - g_object_unref(activation->window); - g_object_unref(activation->view); - g_free(activation); -} - -static void deferred_split_target_disable_free(DeferredSplitTargetDisable *disable) { - g_object_unref(disable->window); - g_object_unref(disable->view); - g_free(disable); -} - -static gboolean finish_split_target_disable(gpointer user_data) { - DeferredSplitTargetDisable *disable = user_data; - - if (!adw_tab_view_get_is_transferring_page(disable->view) && !disable->window->splitting_detached_tab) { - kosmos_pane_dnd_set_split_targets_enabled(disable->window, FALSE); - } - - return G_SOURCE_REMOVE; -} - -static gboolean finish_pending_tab_activation(gpointer user_data) { - DeferredTabActivation *activation = user_data; - AdwTabView *view = activation->view; - PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); - PendingTabActivation pending_copy = {0}; - - if (gtk_widget_get_root(GTK_WIDGET(view)) == NULL || pending == NULL || adw_tab_view_get_is_transferring_page(view)) { - kosmos_tabbed_pane_clear_pending_activation(view); - return G_SOURCE_REMOVE; - } - - pending_copy = *pending; - kosmos_tabbed_pane_clear_pending_activation(view); - request_tab_activation(activation->window, pending_copy.workspace_id, pending_copy.pane_id, pending_copy.tab_id); - - return G_SOURCE_REMOVE; -} - -static void tab_bar_released(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data) { - (void)gesture; - (void)n_press; - (void)x; - (void)y; - - TabBarActivation *activation = user_data; - AdwTabView *view = activation->view; - PendingTabActivation *pending = g_object_get_data(G_OBJECT(view), "pending-tab-activation"); - - g_object_set_data(G_OBJECT(view), "tab-press-active", NULL); - if (pending == NULL || adw_tab_view_get_is_transferring_page(view)) { - kosmos_tabbed_pane_clear_pending_activation(view); - return; - } - - DeferredTabActivation *deferred = g_new(DeferredTabActivation, 1); - deferred->window = g_object_ref(activation->window); - deferred->view = g_object_ref(view); - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, finish_pending_tab_activation, deferred, (GDestroyNotify)deferred_tab_activation_free); -} - -static void tab_transfer_changed(GObject *object, GParamSpec *pspec, gpointer user_data) { - (void)pspec; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - AdwTabView *view = ADW_TAB_VIEW(object); - gboolean transferring = adw_tab_view_get_is_transferring_page(view); - - if (transferring) { - kosmos_pane_dnd_set_split_targets_enabled(self, TRUE); - kosmos_tabbed_pane_clear_pending_activation(view); - } else { - DeferredSplitTargetDisable *disable = g_new(DeferredSplitTargetDisable, 1); - disable->window = g_object_ref(self); - disable->view = g_object_ref(view); - g_timeout_add_full(G_PRIORITY_DEFAULT, 16, finish_split_target_disable, disable, (GDestroyNotify)deferred_split_target_disable_free); - } -} - -static void tab_detached(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - (void)page; - (void)position; - (void)user_data; - - kosmos_tabbed_pane_clear_pending_activation(view); -} - -static void activate_selected_tab(GObject *object, GParamSpec *pspec, gpointer user_data) { - (void)pspec; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - if (self->applying_server_state) { - return; - } - - AdwTabView *view = ADW_TAB_VIEW(object); - if (adw_tab_view_get_is_transferring_page(view)) { - return; - } - - AdwTabPage *page = adw_tab_view_get_selected_page(view); - - if (page == NULL || !kosmos_main_window_ensure_connected(self)) { - return; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id)) { - return; - } - - if (g_object_get_data(G_OBJECT(view), "tab-press-active") != NULL) { - set_pending_tab_activation(view, workspace_id, pane_id, tab_id); - return; - } - - request_tab_activation(self, workspace_id, pane_id, tab_id); -} - -static gboolean close_tab(AdwTabView *view, AdwTabPage *page, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - - if (self->applying_server_state) { - adw_tab_view_close_page_finish(view, page, TRUE); - return GDK_EVENT_STOP; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || - !kosmos_main_window_ensure_connected(self)) { - adw_tab_view_close_page_finish(view, page, FALSE); - return GDK_EVENT_STOP; - } - - GError *error = NULL; - JsonNode *state = NULL; - gboolean closed = kosmos_ipc_client_close_tab(self->ipc_client, workspace_id, pane_id, tab_id, &state, NULL, &error); - adw_tab_view_close_page_finish(view, page, closed); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to close tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } - - return GDK_EVENT_STOP; -} - -static void reorder_tab(AdwTabView *view, AdwTabPage *page, int position, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - kosmos_tabbed_pane_clear_pending_activation(view); - - if (self->applying_server_state) { - return; - } - - if (adw_tab_view_get_is_transferring_page(view)) { - return; - } - - guint64 workspace_id = 0; - guint64 pane_id = 0; - guint64 tab_id = 0; - if (!kosmos_main_window_get_uint64_data(G_OBJECT(page), "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "pane-id", &pane_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &tab_id) || - !kosmos_main_window_ensure_connected(self)) { - kosmos_main_window_refresh_workspace_state(self); - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_reorder_tab(self->ipc_client, workspace_id, pane_id, tab_id, (guint)position, &state, NULL, &error); - - if (error != NULL) { - g_clear_error(&error); - kosmos_main_window_refresh_workspace_state(self); - return; - } - - kosmos_main_window_apply_workspace_state(self, state); - if (state != NULL) { - json_node_unref(state); - } -} - -static void open_blank_tab(GtkButton *button, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - - guint64 workspace_id = 0; - guint64 pane_id = 0; - if (!kosmos_main_window_get_uint64_data(G_OBJECT(button), "workspace-id", &workspace_id) || - !kosmos_main_window_get_uint64_data(G_OBJECT(button), "pane-id", &pane_id) || - !kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_open_tab( - self->ipc_client, - workspace_id, - pane_id, - "Blank", - KOSMOS_IPC_TAB_KIND_BLANK, - &state, - NULL, - &error - ); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to open tab"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static GtkWidget *create_new_tab_button(guint64 workspace_id, guint64 pane_id) { - GtkWidget *button = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_set_tooltip_text(button, "Open blank tab"); - kosmos_main_window_set_uint64_data(G_OBJECT(button), "workspace-id", workspace_id); - kosmos_main_window_set_uint64_data(G_OBJECT(button), "pane-id", pane_id); - - return button; -} - -static const char *tab_kind_or_blank(const char *kind) { - return kind == NULL || kind[0] == '\0' ? "blank" : kind; -} - -static const char *tab_kind_title(const char *kind) { - kind = tab_kind_or_blank(kind); - - if (g_strcmp0(kind, "fileTree") == 0) { - return "File Tree"; - } - if (g_strcmp0(kind, "editor") == 0) { - return "Editor"; - } - if (g_strcmp0(kind, "git") == 0) { - return "Git"; - } - if (g_strcmp0(kind, "search") == 0) { - return "Search"; - } - if (g_strcmp0(kind, "terminal") == 0) { - return "Terminal"; - } - if (g_strcmp0(kind, "settings") == 0) { - return "Settings"; - } - - return "Blank"; -} - -static void tab_kind_change_request_free(TabKindChangeRequest *request) { - g_clear_object(&request->window); - g_free(request); -} - -static void request_tab_kind_change(KosmosIpcTabKind kind, gpointer user_data) { - TabKindChangeRequest *request = user_data; - KosmosMainWindow *self = request->window; - - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - kosmos_ipc_client_set_tab_kind( - self->ipc_client, - request->workspace_id, - request->pane_id, - request->tab_id, - kind, - &state, - NULL, - &error - ); - kosmos_main_window_apply_server_state_or_show_error(self, state, error, "Failed to change tab type"); - - g_clear_error(&error); - if (state != NULL) { - json_node_unref(state); - } -} - -static GtkWidget *create_blank_tab_content( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id -) { - TabKindChangeRequest *request = g_new(TabKindChangeRequest, 1); - request->window = g_object_ref(self); - request->workspace_id = workspace_id; - request->pane_id = pane_id; - request->tab_id = tab_id; - - GtkWidget *content = kosmos_blank_tab_create(request_tab_kind_change, request); - g_object_set_data_full( - G_OBJECT(content), - "tab-kind-change-request", - request, - (GDestroyNotify)tab_kind_change_request_free - ); - - return content; -} - -static GtkWidget *create_unimplemented_tab_content(const char *kind) { - char *text = g_strdup_printf("%s tab content", tab_kind_title(kind)); - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget *label = gtk_label_new(text); - - g_free(text); - - gtk_widget_set_hexpand(content, TRUE); - gtk_widget_set_vexpand(content, TRUE); - gtk_widget_set_halign(label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(label, TRUE); - gtk_widget_set_vexpand(label, TRUE); - gtk_box_append(GTK_BOX(content), label); - - return content; -} - -static GtkWidget *create_tab_content_child( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - const char *kind -) { - if (g_strcmp0(tab_kind_or_blank(kind), "blank") == 0) { - return create_blank_tab_content(self, workspace_id, pane_id, tab_id); - } - if (g_strcmp0(tab_kind_or_blank(kind), "fileTree") == 0) { - return kosmos_file_tree_tab_create(self->ipc_client, workspace_id); - } - - return create_unimplemented_tab_content(kind); -} - -static GtkWidget *create_tab_content( - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - const char *kind -) { - GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - - gtk_widget_set_hexpand(content, TRUE); - gtk_widget_set_vexpand(content, TRUE); - gtk_box_append(GTK_BOX(content), create_tab_content_child(self, workspace_id, pane_id, tab_id, kind)); - g_object_set_data_full(G_OBJECT(content), "tab-kind", g_strdup(tab_kind_or_blank(kind)), g_free); - - return content; -} - -static gboolean tab_content_needs_update(GtkWidget *content, const char *kind) { - const char *current_kind = g_object_get_data(G_OBJECT(content), "tab-kind"); - const char *next_kind = tab_kind_or_blank(kind); - - return g_strcmp0(next_kind, "blank") == 0 || g_strcmp0(current_kind, next_kind) != 0; -} - -static void clear_tab_content(GtkWidget *content) { - GtkWidget *child = gtk_widget_get_first_child(content); - - while (child != NULL) { - GtkWidget *next = gtk_widget_get_next_sibling(child); - gtk_box_remove(GTK_BOX(content), child); - child = next; - } -} - -static void update_tab_content( - AdwTabPage *page, - KosmosMainWindow *self, - guint64 workspace_id, - guint64 pane_id, - guint64 tab_id, - const char *kind -) { - GtkWidget *content = adw_tab_page_get_child(page); - - if (content == NULL || !GTK_IS_BOX(content) || !tab_content_needs_update(content, kind)) { - return; - } - - clear_tab_content(content); - gtk_box_append(GTK_BOX(content), create_tab_content_child(self, workspace_id, pane_id, tab_id, kind)); - g_object_set_data_full(G_OBJECT(content), "tab-kind", g_strdup(tab_kind_or_blank(kind)), g_free); -} - -static void configure_tab_page(AdwTabPage *page, guint64 workspace_id, guint64 pane_id, guint64 tab_id, const char *title) { - adw_tab_page_set_title(page, title); - adw_tab_page_set_tooltip(page, title); - kosmos_main_window_set_uint64_data(G_OBJECT(page), "workspace-id", workspace_id); - kosmos_main_window_set_uint64_data(G_OBJECT(page), "pane-id", pane_id); - kosmos_main_window_set_uint64_data(G_OBJECT(page), "tab-id", tab_id); -} - -static void add_tab_bar_activation_controller(KosmosMainWindow *self, GtkWidget *tab_bar, AdwTabView *tab_view) { - GtkGesture *gesture = gtk_gesture_click_new(); - TabBarActivation *activation = g_new(TabBarActivation, 1); - - activation->window = self; - activation->view = tab_view; - - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), GDK_BUTTON_PRIMARY); - gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture), GTK_PHASE_CAPTURE); - g_object_set_data_full(G_OBJECT(gesture), "tab-bar-activation", activation, g_free); - g_signal_connect(gesture, "pressed", G_CALLBACK(tab_bar_pressed), activation); - g_signal_connect(gesture, "released", G_CALLBACK(tab_bar_released), activation); - gtk_widget_add_controller(tab_bar, GTK_EVENT_CONTROLLER(gesture)); -} - -static AdwTabPage *tab_page_for_id(AdwTabView *view, guint64 tab_id) { - int page_count = adw_tab_view_get_n_pages(view); - - for (int index = 0; index < page_count; index++) { - AdwTabPage *page = adw_tab_view_get_nth_page(view, index); - guint64 page_tab_id = 0; - if (kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) && page_tab_id == tab_id) { - return page; - } - } - - return NULL; -} - -static JsonObject *get_tab_snapshot(JsonArray *tabs, guint index) { - JsonNode *tab_node = json_array_get_element(tabs, index); - - if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { - return NULL; - } - - return json_node_get_object(tab_node); -} - -static gboolean tab_view_matches_snapshot_prefix(AdwTabView *view, JsonArray *tabs) { - int page_count = adw_tab_view_get_n_pages(view); - - if (json_array_get_length(tabs) < (guint)page_count) { - return FALSE; - } - - for (int index = 0; index < page_count; index++) { - AdwTabPage *page = adw_tab_view_get_nth_page(view, index); - JsonObject *tab = get_tab_snapshot(tabs, (guint)index); - guint64 page_tab_id = 0; - guint64 snapshot_tab_id = 0; - - if (tab == NULL || - !kosmos_main_window_get_uint64_data(G_OBJECT(page), "tab-id", &page_tab_id) || - !kosmos_json_get_uint_member(tab, "id", &snapshot_tab_id) || - page_tab_id != snapshot_tab_id) { - return FALSE; - } - } - - return TRUE; -} - -static gboolean tab_view_matches_snapshot_exact(AdwTabView *view, JsonArray *tabs) { - return json_array_get_length(tabs) == (guint)adw_tab_view_get_n_pages(view) && - tab_view_matches_snapshot_prefix(view, tabs); -} - -gboolean kosmos_tabbed_pane_update_from_pane_view( - KosmosMainWindow *self, - AdwTabView *view, - JsonObject *pane, - guint64 workspace_id, - guint64 active_pane_id, - gboolean allow_append -) { - guint64 pane_id = 0; - guint64 active_tab_id = 0; - JsonArray *tabs = kosmos_json_get_array_member(pane, "tabs"); - - if (!kosmos_json_get_uint_member(pane, "id", &pane_id) || - !kosmos_json_get_uint_member(pane, "activeTabId", &active_tab_id) || - tabs == NULL) { - return FALSE; - } - - if (allow_append) { - if (!tab_view_matches_snapshot_prefix(view, tabs)) { - return FALSE; - } - } else if (!tab_view_matches_snapshot_exact(view, tabs)) { - return FALSE; - } - - gboolean was_applying = self->applying_server_state; - self->applying_server_state = TRUE; - - AdwTabPage *active_page = NULL; - guint tab_count = json_array_get_length(tabs); - for (guint index = 0; index < tab_count; index++) { - JsonObject *tab = get_tab_snapshot(tabs, index); - guint64 tab_id = 0; - const char *title = NULL; - const char *kind = NULL; - if (tab == NULL || - !kosmos_json_get_uint_member(tab, "id", &tab_id) || - !kosmos_json_get_string_member(tab, "title", &title)) { - continue; - } - kosmos_json_get_string_member(tab, "kind", &kind); - - AdwTabPage *page = tab_page_for_id(view, tab_id); - if (page == NULL) { - if (!allow_append) { - self->applying_server_state = was_applying; - return FALSE; - } - - page = adw_tab_view_append(view, create_tab_content(self, workspace_id, pane_id, tab_id, kind)); - } else { - update_tab_content(page, self, workspace_id, pane_id, tab_id, kind); - } - - configure_tab_page(page, workspace_id, pane_id, tab_id, title); - if (tab_id == active_tab_id) { - active_page = page; - } - } - - if (active_page != NULL && adw_tab_view_get_selected_page(view) != active_page) { - adw_tab_view_set_selected_page(view, active_page); - } - - (void)active_pane_id; - - GtkWidget *tab_bar = g_object_get_data(G_OBJECT(view), "tab-bar"); - GtkWidget *new_tab_button = g_object_get_data(G_OBJECT(view), "new-tab-button"); - if (tab_bar != NULL && new_tab_button != NULL) { - kosmos_pane_dnd_configure_single_tab( - self, - tab_bar, - new_tab_button, - view, - workspace_id, - pane_id, - (int)tab_count, - active_page - ); - } - - self->applying_server_state = was_applying; - kosmos_main_window_register_pane_view(self, pane_id, view); - return TRUE; -} - -gboolean kosmos_tabbed_pane_update_from_pane(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, guint64 active_pane_id) { - guint64 pane_id = 0; - if (!kosmos_json_get_uint_member(pane, "id", &pane_id)) { - return FALSE; - } - - AdwTabView *view = kosmos_main_window_pane_view_for(self, pane_id); - if (view == NULL) { - return FALSE; - } - - return kosmos_tabbed_pane_update_from_pane_view(self, view, pane, workspace_id, active_pane_id, TRUE); -} - -GtkWidget *kosmos_tabbed_pane_create(KosmosMainWindow *self, JsonObject *pane, guint64 workspace_id, gboolean is_active_pane) { - guint64 pane_id = 0; - guint64 active_tab_id = 0; - JsonArray *tabs = kosmos_json_get_array_member(pane, "tabs"); - - if (!kosmos_json_get_uint_member(pane, "id", &pane_id) || - !kosmos_json_get_uint_member(pane, "activeTabId", &active_tab_id) || - tabs == NULL) { - return kosmos_main_window_create_label("Invalid pane snapshot.", "error"); - } - - GtkWidget *container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_hexpand(container, TRUE); - gtk_widget_set_vexpand(container, TRUE); - gtk_widget_set_size_request(container, KOSMOS_MIN_PANE_WIDTH, KOSMOS_MIN_PANE_HEIGHT); - - (void)is_active_pane; - - AdwTabView *tab_view = adw_tab_view_new(); - AdwTabBar *tab_bar = adw_tab_bar_new(); - GtkWidget *new_tab_button = create_new_tab_button(workspace_id, pane_id); - adw_tab_bar_set_view(tab_bar, tab_view); - adw_tab_bar_set_end_action_widget(tab_bar, new_tab_button); - adw_tab_bar_set_autohide(tab_bar, FALSE); - adw_tab_bar_set_expand_tabs(tab_bar, FALSE); - adw_tab_view_set_shortcuts( - tab_view, - ADW_TAB_VIEW_SHORTCUT_CONTROL_TAB | - ADW_TAB_VIEW_SHORTCUT_CONTROL_SHIFT_TAB | - ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_UP | - ADW_TAB_VIEW_SHORTCUT_CONTROL_PAGE_DOWN | - ADW_TAB_VIEW_SHORTCUT_ALT_DIGITS | - ADW_TAB_VIEW_SHORTCUT_ALT_ZERO - ); - gtk_widget_set_hexpand(GTK_WIDGET(tab_view), TRUE); - gtk_widget_set_vexpand(GTK_WIDGET(tab_view), TRUE); - - guint tab_count = json_array_get_length(tabs); - AdwTabPage *active_page = NULL; - for (guint index = 0; index < tab_count; index++) { - JsonNode *tab_node = json_array_get_element(tabs, index); - if (tab_node == NULL || !JSON_NODE_HOLDS_OBJECT(tab_node)) { - continue; - } - - JsonObject *tab = json_node_get_object(tab_node); - guint64 tab_id = 0; - const char *title = NULL; - const char *kind = NULL; - if (!kosmos_json_get_uint_member(tab, "id", &tab_id) || !kosmos_json_get_string_member(tab, "title", &title)) { - continue; - } - kosmos_json_get_string_member(tab, "kind", &kind); - - GtkWidget *content = create_tab_content(self, workspace_id, pane_id, tab_id, kind); - AdwTabPage *page = adw_tab_view_append(tab_view, content); - configure_tab_page(page, workspace_id, pane_id, tab_id, title); - - if (tab_id == active_tab_id) { - active_page = page; - } - } - - if (active_page != NULL) { - adw_tab_view_set_selected_page(tab_view, active_page); - } - - kosmos_main_window_register_pane_view(self, pane_id, tab_view); - kosmos_main_window_set_uint64_data(G_OBJECT(container), "pane-id", pane_id); - g_object_set_data(G_OBJECT(container), "tab-view", tab_view); - g_object_set_data(G_OBJECT(tab_view), "pane-container", container); - g_object_set_data(G_OBJECT(tab_view), "tab-bar", GTK_WIDGET(tab_bar)); - g_object_set_data(G_OBJECT(tab_view), "new-tab-button", new_tab_button); - - add_tab_bar_activation_controller(self, GTK_WIDGET(tab_bar), tab_view); - kosmos_pane_dnd_configure_single_tab( - self, - GTK_WIDGET(tab_bar), - new_tab_button, - tab_view, - workspace_id, - pane_id, - (int)tab_count, - active_page - ); - - gtk_box_append(GTK_BOX(container), GTK_WIDGET(tab_bar)); - gtk_box_append(GTK_BOX(container), kosmos_pane_dnd_create_split_overlay(self, tab_view, pane_id)); - g_signal_connect(tab_view, "notify::selected-page", G_CALLBACK(activate_selected_tab), self); - g_signal_connect(tab_view, "notify::is-transferring-page", G_CALLBACK(tab_transfer_changed), self); - g_signal_connect(tab_view, "close-page", G_CALLBACK(close_tab), self); - g_signal_connect(tab_view, "create-window", G_CALLBACK(kosmos_pane_dnd_create_split_sink_for_detached_tab), self); - g_signal_connect(tab_view, "page-detached", G_CALLBACK(tab_detached), NULL); - g_signal_connect(tab_view, "page-reordered", G_CALLBACK(reorder_tab), self); - g_signal_connect(new_tab_button, "clicked", G_CALLBACK(open_blank_tab), self); - - return container; -} diff --git a/desktop/src/ui/kosmos_workspace_switcher.c b/desktop/src/ui/kosmos_workspace_switcher.c deleted file mode 100644 index 771f53b2..00000000 --- a/desktop/src/ui/kosmos_workspace_switcher.c +++ /dev/null @@ -1,365 +0,0 @@ -#include "ui/kosmos_main_window_private.h" - -void kosmos_workspace_switcher_clear(KosmosMainWindow *self) { - GtkWidget *child = gtk_widget_get_first_child(self->workspace_switcher); - - while (child != NULL) { - GtkWidget *next = gtk_widget_get_next_sibling(child); - gtk_box_remove(GTK_BOX(self->workspace_switcher), child); - child = next; - } - - g_hash_table_remove_all(self->workspace_buttons); - self->add_workspace_button = NULL; -} - -static gboolean get_active_workspace_id(JsonObject *result, guint64 *active_workspace_id) { - JsonNode *node = json_object_get_member(result, "activeWorkspaceId"); - if (node == NULL || !JSON_NODE_HOLDS_VALUE(node)) { - return FALSE; - } - - gint64 signed_value = json_node_get_int(node); - if (signed_value < 0) { - return FALSE; - } - - *active_workspace_id = (guint64)signed_value; - return TRUE; -} - -static void activate_workspace(GtkWidget *button, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - guint64 *workspace_id = g_object_get_data(G_OBJECT(button), "workspace-id"); - - if (GTK_IS_TOGGLE_BUTTON(button) && !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button))) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), TRUE); - return; - } - - if (workspace_id == NULL || !kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - if (!kosmos_ipc_client_activate_workspace(self->ipc_client, *workspace_id, &state, NULL, &error)) { - kosmos_main_window_set_error_status(self, "Failed to activate workspace", error); - g_clear_error(&error); - return; - } - - kosmos_main_window_apply_workspace_state(self, state); - json_node_unref(state); -} - -static void open_workspace_path(KosmosMainWindow *self, const char *path) { - if (!kosmos_main_window_ensure_connected(self)) { - return; - } - - GError *error = NULL; - JsonNode *state = NULL; - if (!kosmos_ipc_client_open_workspace(self->ipc_client, path, &state, NULL, &error)) { - kosmos_main_window_set_error_status(self, "Failed to open workspace", error); - g_clear_error(&error); - return; - } - - kosmos_main_window_apply_workspace_state(self, state); - json_node_unref(state); -} - -static void open_workspace_selected(GObject *source_object, GAsyncResult *result, gpointer user_data) { - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - GError *error = NULL; - GFile *folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source_object), result, &error); - - if (folder == NULL) { - if (!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { - kosmos_main_window_set_error_status(self, "Failed to select workspace", error); - } - - g_clear_error(&error); - g_object_unref(self); - return; - } - - char *path = g_file_get_path(folder); - if (path == NULL) { - kosmos_main_window_set_status(self, "Selected workspace is not a local directory."); - } else { - open_workspace_path(self, path); - } - - g_free(path); - g_object_unref(folder); - g_object_unref(self); -} - -static void open_workspace_picker(GtkButton *button, gpointer user_data) { - (void)button; - - KosmosMainWindow *self = KOSMOS_MAIN_WINDOW(user_data); - GtkFileDialog *dialog = gtk_file_dialog_new(); - gtk_file_dialog_set_title(dialog, "Open Workspace"); - gtk_file_dialog_select_folder(dialog, GTK_WINDOW(self), NULL, open_workspace_selected, g_object_ref(self)); - g_object_unref(dialog); -} - -GtkWidget *kosmos_workspace_switcher_ensure_add_button(KosmosMainWindow *self, gboolean sensitive) { - if (self->add_workspace_button == NULL) { - self->add_workspace_button = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_set_tooltip_text(self->add_workspace_button, "Open workspace"); - gtk_box_append(GTK_BOX(self->workspace_switcher), self->add_workspace_button); - g_signal_connect(self->add_workspace_button, "clicked", G_CALLBACK(open_workspace_picker), self); - } - - gtk_widget_set_sensitive(self->add_workspace_button, sensitive); - return self->add_workspace_button; -} - -static void split_workspace_name(const char *name, char **initial, char **suffix) { - if (name == NULL || name[0] == '\0') { - *initial = g_strdup("?"); - *suffix = g_strdup(""); - return; - } - - const char *suffix_start = g_utf8_next_char(name); - *initial = g_strndup(name, suffix_start - name); - *suffix = g_strdup(suffix_start); -} - -static void update_workspace_button_name(GtkWidget *button, const char *name) { - GtkWidget *initial_label = g_object_get_data(G_OBJECT(button), "workspace-initial-label"); - GtkWidget *suffix_label = g_object_get_data(G_OBJECT(button), "workspace-suffix-label"); - char *initial = NULL; - char *suffix = NULL; - - split_workspace_name(name, &initial, &suffix); - gtk_label_set_text(GTK_LABEL(initial_label), initial); - gtk_label_set_text(GTK_LABEL(suffix_label), suffix); - - g_free(initial); - g_free(suffix); -} - -static void set_workspace_button_active(GtkWidget *button, gboolean active) { - GtkWidget *revealer = g_object_get_data(G_OBJECT(button), "workspace-suffix-revealer"); - - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); - gtk_revealer_set_reveal_child(GTK_REVEALER(revealer), active); -} - -static GtkWidget *create_workspace_button(guint64 workspace_id, const char *name) { - GtkWidget *button = gtk_toggle_button_new(); - GtkWidget *label_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - GtkWidget *initial_label = gtk_label_new(NULL); - GtkWidget *suffix_label = gtk_label_new(NULL); - GtkWidget *revealer = gtk_revealer_new(); - - gtk_widget_set_halign(label_box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(label_box, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(initial_label), 0.5f); - - gtk_revealer_set_transition_type(GTK_REVEALER(revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT); - gtk_revealer_set_transition_duration(GTK_REVEALER(revealer), 180); - gtk_revealer_set_child(GTK_REVEALER(revealer), suffix_label); - - gtk_box_append(GTK_BOX(label_box), initial_label); - gtk_box_append(GTK_BOX(label_box), revealer); - gtk_button_set_child(GTK_BUTTON(button), label_box); - - g_object_set_data(G_OBJECT(button), "workspace-initial-label", initial_label); - g_object_set_data(G_OBJECT(button), "workspace-suffix-label", suffix_label); - g_object_set_data(G_OBJECT(button), "workspace-suffix-revealer", revealer); - - guint64 *button_workspace_id = g_new(guint64, 1); - *button_workspace_id = workspace_id; - g_object_set_data_full(G_OBJECT(button), "workspace-id", button_workspace_id, g_free); - - update_workspace_button_name(button, name); - - return button; -} - -static GtkWidget *workspace_button_for(KosmosMainWindow *self, guint64 workspace_id, const char *name) { - GtkWidget *button = g_hash_table_lookup(self->workspace_buttons, &workspace_id); - - if (button != NULL) { - update_workspace_button_name(button, name); - return button; - } - - button = create_workspace_button(workspace_id, name); - guint64 *key = g_new(guint64, 1); - *key = workspace_id; - g_hash_table_insert(self->workspace_buttons, key, button); - gtk_box_append(GTK_BOX(self->workspace_switcher), button); - g_signal_connect(button, "clicked", G_CALLBACK(activate_workspace), self); - - return button; -} - -static void remove_stale_workspace_buttons(KosmosMainWindow *self, GHashTable *seen_workspace_ids) { - GHashTableIter iter; - gpointer key = NULL; - gpointer value = NULL; - - g_hash_table_iter_init(&iter, self->workspace_buttons); - while (g_hash_table_iter_next(&iter, &key, &value)) { - if (!g_hash_table_contains(seen_workspace_ids, key)) { - gtk_box_remove(GTK_BOX(self->workspace_switcher), GTK_WIDGET(value)); - g_hash_table_iter_remove(&iter); - } - } -} - -static GtkWidget *update_workspace_button( - KosmosMainWindow *self, - JsonObject *workspace, - guint64 active_workspace_id, - gboolean has_active_workspace -) { - guint64 workspace_id = 0; - const char *name = NULL; - - if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id) || - !kosmos_json_get_string_member(workspace, "name", &name)) { - return NULL; - } - - GtkWidget *button = workspace_button_for(self, workspace_id, name); - set_workspace_button_active(button, has_active_workspace && workspace_id == active_workspace_id); - - return button; -} - -static JsonObject *find_active_workspace(JsonArray *workspaces, guint64 active_workspace_id, gboolean has_active_workspace) { - if (!has_active_workspace) { - return NULL; - } - - guint workspace_count = json_array_get_length(workspaces); - for (guint index = 0; index < workspace_count; index++) { - JsonNode *workspace_node = json_array_get_element(workspaces, index); - if (workspace_node == NULL || !JSON_NODE_HOLDS_OBJECT(workspace_node)) { - continue; - } - - JsonObject *workspace = json_node_get_object(workspace_node); - guint64 workspace_id = 0; - if (kosmos_json_get_uint_member(workspace, "id", &workspace_id) && workspace_id == active_workspace_id) { - return workspace; - } - } - - return NULL; -} - -void kosmos_main_window_apply_workspace_state(KosmosMainWindow *self, JsonNode *state) { - gboolean was_applying = self->applying_server_state; - self->applying_server_state = TRUE; - - if (state == NULL || !JSON_NODE_HOLDS_OBJECT(state)) { - kosmos_main_window_set_status(self, "Workspace state is unavailable."); - self->applying_server_state = was_applying; - return; - } - - JsonObject *result = json_node_get_object(state); - JsonNode *workspaces_node = json_object_get_member(result, "workspaces"); - if (workspaces_node == NULL || !JSON_NODE_HOLDS_ARRAY(workspaces_node)) { - kosmos_main_window_set_status(self, "Workspace state is missing workspaces."); - self->applying_server_state = was_applying; - return; - } - - guint64 active_workspace_id = 0; - gboolean has_active_workspace = get_active_workspace_id(result, &active_workspace_id); - JsonArray *workspaces = json_node_get_array(workspaces_node); - guint workspace_count = json_array_get_length(workspaces); - JsonObject *active_workspace = find_active_workspace(workspaces, active_workspace_id, has_active_workspace); - GHashTable *seen_workspace_ids = g_hash_table_new_full(g_int64_hash, g_int64_equal, g_free, NULL); - GtkWidget *previous_button = NULL; - - for (guint index = 0; index < workspace_count; index++) { - JsonNode *workspace_node = json_array_get_element(workspaces, index); - if (workspace_node != NULL && JSON_NODE_HOLDS_OBJECT(workspace_node)) { - JsonObject *workspace = json_node_get_object(workspace_node); - guint64 workspace_id = 0; - - if (!kosmos_json_get_uint_member(workspace, "id", &workspace_id)) { - continue; - } - - guint64 *seen_id = g_new(guint64, 1); - *seen_id = workspace_id; - g_hash_table_add(seen_workspace_ids, seen_id); - - GtkWidget *button = update_workspace_button(self, workspace, active_workspace_id, has_active_workspace); - if (button != NULL) { - gtk_box_reorder_child_after(GTK_BOX(self->workspace_switcher), button, previous_button); - previous_button = button; - } - } - } - - remove_stale_workspace_buttons(self, seen_workspace_ids); - gtk_box_reorder_child_after( - GTK_BOX(self->workspace_switcher), - kosmos_workspace_switcher_ensure_add_button(self, TRUE), - previous_button - ); - g_hash_table_unref(seen_workspace_ids); - - (void)workspace_count; - if (active_workspace != NULL) { - gboolean hide_until_layout_applied = gtk_widget_get_first_child(self->content_area) != NULL && - (!self->has_rendered_workspace || self->rendered_workspace_id != active_workspace_id); - char *layout_signature = kosmos_pane_layout_create_signature(active_workspace); - - if (hide_until_layout_applied) { - kosmos_pane_layout_begin_hidden_apply(self); - } - - if (layout_signature != NULL && - self->layout_signature != NULL && - g_strcmp0(layout_signature, self->layout_signature) == 0 && - kosmos_pane_layout_update_active_workspace_in_place(self, active_workspace)) { - g_free(layout_signature); - self->rendered_workspace_id = active_workspace_id; - self->has_rendered_workspace = TRUE; - if (hide_until_layout_applied) { - kosmos_pane_layout_finish_hidden_apply(self); - } - self->applying_server_state = was_applying; - return; - } - - kosmos_pane_layout_render_active_workspace(self, active_workspace); - g_clear_pointer(&self->layout_signature, g_free); - self->layout_signature = layout_signature; - self->rendered_workspace_id = active_workspace_id; - self->has_rendered_workspace = TRUE; - if (hide_until_layout_applied) { - kosmos_pane_layout_finish_hidden_apply(self); - } - self->applying_server_state = was_applying; - return; - } - - kosmos_pane_layout_render_active_workspace(self, active_workspace); - self->has_rendered_workspace = FALSE; - self->applying_server_state = was_applying; -} - -GtkWidget *kosmos_workspace_switcher_create(KosmosMainWindow *self) { - self->workspace_switcher = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_add_css_class(self->workspace_switcher, "linked"); - gtk_widget_set_halign(self->workspace_switcher, GTK_ALIGN_CENTER); - kosmos_workspace_switcher_ensure_add_button(self, FALSE); - - return self->workspace_switcher; -} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 00000000..446e6c24 --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ES2024", "DOM"], + "target": "ES2024", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "types": ["node", "vite/client"], + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "isolatedModules": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"] +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts new file mode 100644 index 00000000..92644e0a --- /dev/null +++ b/desktop/vite.config.ts @@ -0,0 +1,22 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const rootDirectory = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + base: "./", + root: "src/renderer", + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(rootDirectory, "src"), + }, + }, + build: { + emptyOutDir: false, + outDir: "../../dist/renderer", + }, +}); diff --git a/scripts/run.sh b/scripts/run.sh index d25d5678..4c2c91fd 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -3,8 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SERVER_BIN="$ROOT_DIR/target/debug/kosmos-server" -DESKTOP_BUILD_DIR="$ROOT_DIR/desktop/build" -DESKTOP_BIN="$DESKTOP_BUILD_DIR/kosmos-desktop" +DESKTOP_DIR="$ROOT_DIR/desktop" RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}" export KOSMOS_SOCKET="${KOSMOS_SOCKET:-$RUNTIME_DIR/kosmos/server.sock}" @@ -20,12 +19,10 @@ cleanup() { trap cleanup EXIT INT TERM cargo build --package kosmos-server -if [[ -f "$DESKTOP_BUILD_DIR/build.ninja" ]]; then - meson setup --reconfigure "$DESKTOP_BUILD_DIR" "$ROOT_DIR/desktop" -else - meson setup "$DESKTOP_BUILD_DIR" "$ROOT_DIR/desktop" +if [[ ! -d "$DESKTOP_DIR/node_modules" ]]; then + bun install --cwd "$DESKTOP_DIR" fi -meson compile -C "$DESKTOP_BUILD_DIR" +bun run --cwd "$DESKTOP_DIR" build "$SERVER_BIN" & server_pid="$!" @@ -48,4 +45,4 @@ if [[ ! -S "$KOSMOS_SOCKET" ]]; then exit 1 fi -"$DESKTOP_BIN" "$@" +bun run --cwd "$DESKTOP_DIR" start -- "$@" diff --git a/server/src/ipc/messages/envelope.rs b/server/src/ipc/messages/envelope.rs index 5a449b15..dad01c1f 100644 --- a/server/src/ipc/messages/envelope.rs +++ b/server/src/ipc/messages/envelope.rs @@ -62,7 +62,6 @@ pub(crate) enum Domain { Workspace, Pane, Tab, - FileTree, } #[derive(Debug, Serialize)] diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs deleted file mode 100644 index 246684c9..00000000 --- a/server/src/ipc/messages/file_tree.rs +++ /dev/null @@ -1,190 +0,0 @@ -use core::file_tree::{ - FileTree, FileTreeCreateKind, FileTreeEntry, FileTreeEntryKind, FileTreeOptions, -}; -use serde::{Deserialize, Serialize}; - -use super::pane::WorkspaceIdParam; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct FileTreeParams { - pub(crate) workspace_id: Option, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl FileTreeParams { - pub(crate) fn options(&self) -> FileTreeOptions { - let defaults = FileTreeOptions::default(); - - FileTreeOptions::new( - self.max_depth.unwrap_or(defaults.max_depth()), - self.max_entries_per_directory - .unwrap_or(defaults.max_entries_per_directory()), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CreateFileTreeEntryParams { - pub(crate) workspace_id: Option, - pub(crate) parent_path: String, - pub(crate) name: String, - pub(crate) kind: FileTreeCreateKindPayload, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl CreateFileTreeEntryParams { - pub(crate) fn options(&self) -> FileTreeOptions { - options(self.max_depth, self.max_entries_per_directory) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RenameFileTreeEntryParams { - pub(crate) workspace_id: Option, - pub(crate) path: String, - pub(crate) name: String, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl RenameFileTreeEntryParams { - pub(crate) fn options(&self) -> FileTreeOptions { - options(self.max_depth, self.max_entries_per_directory) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DeleteFileTreeEntryParams { - pub(crate) workspace_id: Option, - pub(crate) path: String, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl DeleteFileTreeEntryParams { - pub(crate) fn options(&self) -> FileTreeOptions { - options(self.max_depth, self.max_entries_per_directory) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MoveFileTreeEntryParams { - pub(crate) workspace_id: Option, - pub(crate) path: String, - pub(crate) target_directory_path: String, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl MoveFileTreeEntryParams { - pub(crate) fn options(&self) -> FileTreeOptions { - options(self.max_depth, self.max_entries_per_directory) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CopyFileTreeEntryParams { - pub(crate) workspace_id: Option, - pub(crate) path: String, - pub(crate) target_directory_path: String, - max_depth: Option, - max_entries_per_directory: Option, -} - -impl CopyFileTreeEntryParams { - pub(crate) fn options(&self) -> FileTreeOptions { - options(self.max_depth, self.max_entries_per_directory) - } -} - -#[derive(Clone, Copy, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum FileTreeCreateKindPayload { - File, - Directory, -} - -impl From for FileTreeCreateKind { - fn from(value: FileTreeCreateKindPayload) -> Self { - match value { - FileTreeCreateKindPayload::File => Self::File, - FileTreeCreateKindPayload::Directory => Self::Directory, - } - } -} - -fn options(max_depth: Option, max_entries_per_directory: Option) -> FileTreeOptions { - let defaults = FileTreeOptions::default(); - - FileTreeOptions::new( - max_depth.unwrap_or(defaults.max_depth()), - max_entries_per_directory.unwrap_or(defaults.max_entries_per_directory()), - ) -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct FileTreeSnapshot { - root: FileTreeEntrySnapshot, -} - -impl FileTreeSnapshot { - pub(crate) fn from_tree(tree: &FileTree) -> Self { - Self { - root: FileTreeEntrySnapshot::from_entry(tree.root()), - } - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct FileTreeEntrySnapshot { - name: String, - path: String, - kind: FileTreeEntryKindPayload, - children: Vec, - children_truncated: bool, - #[serde(skip_serializing_if = "Option::is_none")] - read_error: Option, -} - -impl FileTreeEntrySnapshot { - fn from_entry(entry: &FileTreeEntry) -> Self { - Self { - name: entry.name().to_owned(), - path: entry.path().to_string_lossy().into_owned(), - kind: entry.kind().into(), - children: entry.children().iter().map(Self::from_entry).collect(), - children_truncated: entry.children_truncated(), - read_error: entry.read_error().map(str::to_owned), - } - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -enum FileTreeEntryKindPayload { - Directory, - File, - Symlink, - Other, -} - -impl From for FileTreeEntryKindPayload { - fn from(value: FileTreeEntryKind) -> Self { - match value { - FileTreeEntryKind::Directory => Self::Directory, - FileTreeEntryKind::File => Self::File, - FileTreeEntryKind::Symlink => Self::Symlink, - FileTreeEntryKind::Other => Self::Other, - } - } -} diff --git a/server/src/ipc/messages/mod.rs b/server/src/ipc/messages/mod.rs index c63601b2..3e35c43e 100644 --- a/server/src/ipc/messages/mod.rs +++ b/server/src/ipc/messages/mod.rs @@ -1,5 +1,4 @@ pub(crate) mod envelope; -pub(crate) mod file_tree; pub(crate) mod pane; pub(crate) mod tab; pub(crate) mod workspace; diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs deleted file mode 100644 index d1cc5950..00000000 --- a/server/src/ipc/router/file_tree.rs +++ /dev/null @@ -1,151 +0,0 @@ -use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; -use super::super::messages::file_tree::{ - CopyFileTreeEntryParams, CreateFileTreeEntryParams, DeleteFileTreeEntryParams, FileTreeParams, - FileTreeSnapshot, MoveFileTreeEntryParams, RenameFileTreeEntryParams, -}; -use super::{parse_params, unsupported_action}; - -pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match request.action.as_str() { - "list" => list_file_tree(state, request), - "create" => create_file_tree_entry(state, request), - "rename" => rename_file_tree_entry(state, request), - "delete" => delete_file_tree_entry(state, request), - "move" => move_file_tree_entry(state, request), - "copy" => copy_file_tree_entry(state, request), - _ => unsupported_action(request), - } -} - -fn list_file_tree(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - match state.file_tree(params.workspace_id.map(Into::into), params.options()) { - Ok(tree) => ServerMessage::ok(request.id, FileTreeSnapshot::from_tree(&tree)), - Err(error) => ServerMessage::error( - request.id, - file_tree_error_code(&error), - error.to_string(), - ), - } - } - Err(response) => response, - } -} - -fn create_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - let options = params.options(); - file_tree_response( - request.id, - state.create_file_tree_entry( - params.workspace_id.map(Into::into), - params.parent_path, - ¶ms.name, - params.kind.into(), - options, - ), - ) - } - Err(response) => response, - } -} - -fn rename_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - let options = params.options(); - file_tree_response( - request.id, - state.rename_file_tree_entry( - params.workspace_id.map(Into::into), - params.path, - ¶ms.name, - options, - ), - ) - } - Err(response) => response, - } -} - -fn delete_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - let options = params.options(); - file_tree_response( - request.id, - state.delete_file_tree_entry( - params.workspace_id.map(Into::into), - params.path, - options, - ), - ) - } - Err(response) => response, - } -} - -fn move_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - let options = params.options(); - file_tree_response( - request.id, - state.move_file_tree_entry( - params.workspace_id.map(Into::into), - params.path, - params.target_directory_path, - options, - ), - ) - } - Err(response) => response, - } -} - -fn copy_file_tree_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { - match parse_params::(request) { - Ok(params) => { - let options = params.options(); - file_tree_response( - request.id, - state.copy_file_tree_entry( - params.workspace_id.map(Into::into), - params.path, - params.target_directory_path, - options, - ), - ) - } - Err(response) => response, - } -} - -fn file_tree_response( - request_id: u64, - result: Result, -) -> ServerMessage { - match result { - Ok(tree) => ServerMessage::ok(request_id, FileTreeSnapshot::from_tree(&tree)), - Err(error) => { - ServerMessage::error(request_id, file_tree_error_code(&error), error.to_string()) - } - } -} - -fn file_tree_error_code(error: &core::file_tree::FileTreeError) -> &'static str { - match error { - core::file_tree::FileTreeError::WorkspaceUnavailable => "workspace.not_found", - core::file_tree::FileTreeError::AlreadyExists(_) => "file_tree.already_exists", - core::file_tree::FileTreeError::InvalidCopy => "file_tree.invalid_copy", - core::file_tree::FileTreeError::InvalidMove => "file_tree.invalid_move", - core::file_tree::FileTreeError::InvalidName(_) => "file_tree.invalid_name", - core::file_tree::FileTreeError::InvalidPath(_) => "file_tree.invalid_path", - core::file_tree::FileTreeError::Io { .. } => "file_tree.read_failed", - core::file_tree::FileTreeError::NotDirectory(_) => "file_tree.not_directory", - core::file_tree::FileTreeError::OutsideWorkspace(_) => "file_tree.outside_workspace", - core::file_tree::FileTreeError::RootOperation => "file_tree.root_operation", - } -} diff --git a/server/src/ipc/router/mod.rs b/server/src/ipc/router/mod.rs index 6d896d97..8a1a1988 100644 --- a/server/src/ipc/router/mod.rs +++ b/server/src/ipc/router/mod.rs @@ -1,4 +1,3 @@ -mod file_tree; mod pane; mod tab; mod workspace; @@ -13,7 +12,6 @@ pub(crate) fn route(state: &mut core::State, request: RequestEnvelope) -> Server Domain::Workspace => workspace::route(state, &request), Domain::Pane => pane::route(state, &request), Domain::Tab => tab::route(state, &request), - Domain::FileTree => file_tree::route(state, &request), } } From fd7d39da165bce6ba1c34f4f19f3a703c7e42b56 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 12:40:49 -0300 Subject: [PATCH 17/98] feat: add layout border and header --- desktop/components.json | 10 +++++----- desktop/src/renderer/App.tsx | 9 ++++++--- desktop/src/renderer/components/internal/header.tsx | 7 +++++++ desktop/src/{ => renderer}/components/ui/button.tsx | 2 +- desktop/src/renderer/index.css | 9 ++++++--- desktop/src/{ => renderer}/lib/utils.ts | 0 6 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 desktop/src/renderer/components/internal/header.tsx rename desktop/src/{ => renderer}/components/ui/button.tsx (98%) rename desktop/src/{ => renderer}/lib/utils.ts (100%) diff --git a/desktop/components.json b/desktop/components.json index 31c48474..8d01c872 100644 --- a/desktop/components.json +++ b/desktop/components.json @@ -13,11 +13,11 @@ "iconLibrary": "lucide", "rtl": false, "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" + "components": "@/renderer/components", + "utils": "@/renderer/lib/utils", + "ui": "@/renderer/components/ui", + "lib": "@/renderer/lib", + "hooks": "@/renderer/hooks" }, "menuColor": "default", "menuAccent": "subtle", diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index a61b0fab..98b463d1 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -1,9 +1,12 @@ -import { Button } from "@/components/ui/button"; +import { Button } from "@/renderer/components/ui/button"; +import { Header } from "@/renderer/components/internal/header"; export function App() { return ( -
-
+
+
+ +

Hello world

diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx new file mode 100644 index 00000000..8ad9ce4f --- /dev/null +++ b/desktop/src/renderer/components/internal/header.tsx @@ -0,0 +1,7 @@ +export function Header() { + return ( +
+

Kosmos

+
+ ); +} diff --git a/desktop/src/components/ui/button.tsx b/desktop/src/renderer/components/ui/button.tsx similarity index 98% rename from desktop/src/components/ui/button.tsx rename to desktop/src/renderer/components/ui/button.tsx index b0336017..25ecc6b6 100644 --- a/desktop/src/components/ui/button.tsx +++ b/desktop/src/renderer/components/ui/button.tsx @@ -1,7 +1,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" +import { cn } from "@/renderer/lib/utils" const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", diff --git a/desktop/src/renderer/index.css b/desktop/src/renderer/index.css index 19a81c89..854a8e54 100644 --- a/desktop/src/renderer/index.css +++ b/desktop/src/renderer/index.css @@ -122,9 +122,12 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply h-full overflow-hidden bg-muted p-2 text-foreground; } html { - @apply font-sans; + @apply h-full overflow-hidden font-sans; } -} \ No newline at end of file + #root { + @apply h-full overflow-hidden; + } +} diff --git a/desktop/src/lib/utils.ts b/desktop/src/renderer/lib/utils.ts similarity index 100% rename from desktop/src/lib/utils.ts rename to desktop/src/renderer/lib/utils.ts From dc8184067207b9c6e8f9f34deffc225ba8883f09 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 13:05:00 -0300 Subject: [PATCH 18/98] feat: add workspace list --- .gitignore | 6 - AGENTS.md | 8 + core/.gitignore | 1 + core/src/persistence/mod.rs | 73 +++++++-- .../renderer/components/internal/header.tsx | 149 +++++++++++++++++- .../renderer/components/ui/button-group.tsx | 87 ++++++++++ .../src/renderer/components/ui/separator.tsx | 23 +++ server/src/ipc/router/mod.rs | 8 +- server/src/ipc/transport/connection.rs | 59 +++++-- 9 files changed, 374 insertions(+), 40 deletions(-) create mode 100644 AGENTS.md create mode 100644 core/.gitignore create mode 100644 desktop/src/renderer/components/ui/button-group.tsx create mode 100644 desktop/src/renderer/components/ui/separator.tsx diff --git a/.gitignore b/.gitignore index 1a051534..ea8c4bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ /target -/core/target - -.claude -.codex -CLAUDE.md -AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a5bfb6db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +- When executing Rust actions like making a new crate, formatting or any other, use cargo commands instead of doing it manually. +- Keep separation of concerns between crates as clean as possible, don't leak unwanted logic into other crates, this helps keep the app's logic untangled, modular and maintainable. +- When writing functions, keep them minimal, they must do one thing and do it flawlessly, avoid writing big functions, instead, divide the problem in smaller pieces, and then consume those small functions in the final solution. +- Avoid code duplication, check if there's an existing piece of logic you can reuse instead of rewriting it again. +- Leave the code better than you found it, if you see something looks off while you're working on this, address it immediately. +- All Kosmos state lives in server/ which consumes the core/ create which has all of the app's logic, keep everything in core/ and then make desktop/ just communicate through IPC to consume it and interact with it. +- NEVER write core logic on desktop/ or server/, keep it strictly on core/, this allows us to keep a clean separation of concerns and reusability. +- Keep client-server UI communication responsive: lightweight interactions must update optimistically or asynchronously where appropriate, never block on full synchronous persistence or slow round trips, and avoid temporary label/disabled-state changes that cause visible layout churn. diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/core/src/persistence/mod.rs b/core/src/persistence/mod.rs index 911305a9..3d1cdabb 100644 --- a/core/src/persistence/mod.rs +++ b/core/src/persistence/mod.rs @@ -26,7 +26,8 @@ impl StateStore { } let store = Self { path }; - store.connection()?; + let connection = store.connection()?; + migrate(&connection)?; Ok(store) } @@ -52,11 +53,18 @@ impl StateStore { Ok(()) } - fn connection(&self) -> Result { - let connection = Connection::open(&self.path)?; - migrate(&connection)?; + pub fn save_active_workspace(&self, state: &State) -> Result<()> { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; - Ok(connection) + replace_active_workspace_metadata(&transaction, state.workspaces().active_workspace_id())?; + transaction.commit()?; + + Ok(()) + } + + fn connection(&self) -> Result { + Ok(Connection::open(&self.path)?) } } @@ -169,12 +177,7 @@ fn clear_state(transaction: &Transaction<'_>) -> Result<()> { } fn save_state(transaction: &Transaction<'_>, state: &State) -> Result<()> { - if let Some(active_workspace_id) = state.workspaces().active_workspace_id() { - transaction.execute( - "INSERT INTO metadata (key, value) VALUES ('active_workspace_id', ?1)", - params![active_workspace_id.value().to_string()], - )?; - } + insert_active_workspace_metadata(transaction, state.workspaces().active_workspace_id())?; for (position, workspace) in state.workspaces().workspaces().iter().enumerate() { let workspace_id = to_i64(workspace.id().value(), "workspace id")?; @@ -200,6 +203,28 @@ fn save_state(transaction: &Transaction<'_>, state: &State) -> Result<()> { Ok(()) } +fn replace_active_workspace_metadata( + transaction: &Transaction<'_>, + active_workspace_id: Option, +) -> Result<()> { + transaction.execute("DELETE FROM metadata WHERE key = 'active_workspace_id'", [])?; + insert_active_workspace_metadata(transaction, active_workspace_id) +} + +fn insert_active_workspace_metadata( + transaction: &Transaction<'_>, + active_workspace_id: Option, +) -> Result<()> { + if let Some(active_workspace_id) = active_workspace_id { + transaction.execute( + "INSERT INTO metadata (key, value) VALUES ('active_workspace_id', ?1)", + params![active_workspace_id.value().to_string()], + )?; + } + + Ok(()) +} + fn save_node( transaction: &Transaction<'_>, workspace_id: WorkspaceId, @@ -653,6 +678,32 @@ mod tests { let _ = fs::remove_file(path); } + #[test] + fn saves_active_workspace_without_full_state_save() { + let path = test_db_path("active-workspace"); + let store = StateStore::open(&path).expect("store should open"); + let mut state = State::new(); + let first_workspace_id = state.open_workspace("/workspaces/first"); + state.open_workspace("/workspaces/second"); + + store.save(&state).expect("state should save"); + + assert!(state.activate_workspace(first_workspace_id)); + store + .save_active_workspace(&state) + .expect("active workspace should save"); + + let loaded = store.load().expect("state should load"); + + assert_eq!( + loaded.workspaces().active_workspace_id(), + Some(first_workspace_id) + ); + assert_eq!(loaded.workspaces().workspaces().len(), 2); + + let _ = fs::remove_file(path); + } + fn test_db_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx index 8ad9ce4f..3f36f9c0 100644 --- a/desktop/src/renderer/components/internal/header.tsx +++ b/desktop/src/renderer/components/internal/header.tsx @@ -1,7 +1,152 @@ +import { Plus } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + activateWorkspace, + listWorkspaces, + openWorkspace, + selectWorkspaceDirectory, + type WorkspaceListSnapshot, +} from "@/renderer/ipc"; +import { Button } from "@/renderer/components/ui/button"; +import { ButtonGroup } from "@/renderer/components/ui/button-group"; +import type { WorkspaceId } from "@/shared/ipc"; + export function Header() { + const [error, setError] = useState(null); + const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); + const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true); + const [snapshot, setSnapshot] = useState(null); + const switchRequestId = useRef(0); + + useEffect(() => { + let isMounted = true; + + listWorkspaces() + .then((nextSnapshot) => { + if (!isMounted) { + return; + } + + setSnapshot(nextSnapshot); + setError(null); + }) + .catch((caughtError: unknown) => { + if (isMounted) { + setError(errorMessage(caughtError)); + } + }) + .finally(() => { + if (isMounted) { + setIsLoadingWorkspaces(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + async function switchWorkspace(workspaceId: WorkspaceId): Promise { + if (workspaceId === snapshot?.activeWorkspaceId || isAddingWorkspace) { + return; + } + + const requestId = switchRequestId.current + 1; + const previousSnapshot = snapshot; + + switchRequestId.current = requestId; + setError(null); + setSnapshot((currentSnapshot) => + currentSnapshot ? { ...currentSnapshot, activeWorkspaceId: workspaceId } : currentSnapshot, + ); + + try { + const nextSnapshot = await activateWorkspace(workspaceId); + + if (switchRequestId.current === requestId) { + setSnapshot(nextSnapshot); + } + } catch (caughtError) { + if (switchRequestId.current === requestId) { + setSnapshot(previousSnapshot); + setError(errorMessage(caughtError)); + } + } + } + + async function addWorkspace(): Promise { + setIsAddingWorkspace(true); + setError(null); + + try { + const directory = await selectWorkspaceDirectory(); + + if (!directory) { + return; + } + + setSnapshot(await openWorkspace(directory)); + } catch (caughtError) { + setError(errorMessage(caughtError)); + } finally { + setIsAddingWorkspace(false); + } + } + return ( -
-

Kosmos

+
+ {error ? ( +

+ {error} +

+ ) : null} + + + {isLoadingWorkspaces ? ( + + ) : ( + snapshot?.workspaces.map((workspace) => { + const isActive = workspace.id === snapshot.activeWorkspaceId; + + return ( + + ); + }) + )} + + +
); } + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return "Unable to communicate with the Kosmos server."; +} diff --git a/desktop/src/renderer/components/ui/button-group.tsx b/desktop/src/renderer/components/ui/button-group.tsx new file mode 100644 index 00000000..befb419e --- /dev/null +++ b/desktop/src/renderer/components/ui/button-group.tsx @@ -0,0 +1,87 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/renderer/lib/utils" +import { Separator } from "@/renderer/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + vertical: + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className + ), + }, + props + ), + render, + state: { + slot: "button-group-text", + }, + }) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/desktop/src/renderer/components/ui/separator.tsx b/desktop/src/renderer/components/ui/separator.tsx new file mode 100644 index 00000000..dda63bc8 --- /dev/null +++ b/desktop/src/renderer/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/renderer/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/server/src/ipc/router/mod.rs b/server/src/ipc/router/mod.rs index 8a1a1988..2551eba8 100644 --- a/server/src/ipc/router/mod.rs +++ b/server/src/ipc/router/mod.rs @@ -7,11 +7,11 @@ use serde::de::DeserializeOwned; use super::messages::envelope::{Domain, RequestEnvelope, ServerMessage}; use super::messages::workspace::WorkspaceListSnapshot; -pub(crate) fn route(state: &mut core::State, request: RequestEnvelope) -> ServerMessage { +pub(crate) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match request.domain { - Domain::Workspace => workspace::route(state, &request), - Domain::Pane => pane::route(state, &request), - Domain::Tab => tab::route(state, &request), + Domain::Workspace => workspace::route(state, request), + Domain::Pane => pane::route(state, request), + Domain::Tab => tab::route(state, request), } } diff --git a/server/src/ipc/transport/connection.rs b/server/src/ipc/transport/connection.rs index 88c65905..fc62a07b 100644 --- a/server/src/ipc/transport/connection.rs +++ b/server/src/ipc/transport/connection.rs @@ -2,7 +2,7 @@ use std::io::{self, BufReader}; use std::os::unix::net::UnixStream; use std::sync::{Arc, Mutex}; -use crate::ipc::messages::envelope::{ClientMessage, ServerMessage}; +use crate::ipc::messages::envelope::{ClientMessage, Domain, RequestEnvelope, ServerMessage}; use crate::ipc::router; use super::codec; @@ -27,22 +27,9 @@ pub(crate) fn handle( .lock() .map_err(|_| io::Error::other("IPC state mutex was poisoned"))?; - let request_id = request.id; - let response = router::route(&mut state, request); - - if response.is_ok() { - if let Err(error) = store.save(&state) { - ServerMessage::error( - request_id, - "persistence.save_failed", - error.to_string(), - ) - } else { - response - } - } else { - response - } + let response = router::route(&mut state, &request); + + persist_successful_request(&request, response, &state, &store) } Err(error) => ServerMessage::error(0, "ipc.invalid_message", error.to_string()), }; @@ -52,3 +39,41 @@ pub(crate) fn handle( Ok(()) } + +fn persist_successful_request( + request: &RequestEnvelope, + response: ServerMessage, + state: &core::State, + store: &core::persistence::StateStore, +) -> ServerMessage { + if !response.is_ok() { + return response; + } + + let result = match persistence_mode(request) { + PersistenceMode::None => Ok(()), + PersistenceMode::ActiveWorkspace => store.save_active_workspace(state), + PersistenceMode::Full => store.save(state), + }; + + match result { + Ok(()) => response, + Err(error) => { + ServerMessage::error(request.id, "persistence.save_failed", error.to_string()) + } + } +} + +fn persistence_mode(request: &RequestEnvelope) -> PersistenceMode { + match (request.domain, request.action.as_str()) { + (Domain::Workspace, "list") => PersistenceMode::None, + (Domain::Workspace, "activate") => PersistenceMode::ActiveWorkspace, + _ => PersistenceMode::Full, + } +} + +enum PersistenceMode { + None, + ActiveWorkspace, + Full, +} From 299dd8fc0e89e432c076b3a5ed21578e401b67e6 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 15:31:54 -0300 Subject: [PATCH 19/98] feat: add custom header --- core/src/state.rs | 34 +++ desktop/src/main/main.ts | 27 +- desktop/src/preload/preload.ts | 9 + .../renderer/components/internal/header.tsx | 135 ++++++++- .../renderer/components/ui/context-menu.tsx | 269 ++++++++++++++++++ desktop/src/renderer/ipc/transport.ts | 12 + desktop/src/shared/ipc/envelope.ts | 3 + 7 files changed, 473 insertions(+), 16 deletions(-) create mode 100644 desktop/src/renderer/components/ui/context-menu.tsx diff --git a/core/src/state.rs b/core/src/state.rs index ac2b25d8..9e4f2ae2 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -46,6 +46,19 @@ impl State { } pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { + let directory = directory.into(); + + if let Some(workspace_id) = self + .workspaces + .workspaces() + .iter() + .find(|workspace| workspace.directory() == directory.as_path()) + .map(Workspace::id) + { + self.workspaces.activate_workspace(workspace_id); + return workspace_id; + } + let workspace_id = self.next_workspace_id(); let initial_pane = self.blank_pane(); let workspace = Workspace::new(workspace_id, directory, initial_pane); @@ -428,6 +441,27 @@ mod tests { assert_eq!(state.workspaces().workspaces().len(), 1); } + #[test] + fn opening_existing_workspace_path_activates_existing_workspace() { + let mut state = State::new(); + + let first_workspace_id = state.open_workspace("/workspaces/first"); + let second_workspace_id = state.open_workspace("/workspaces/second"); + let reopened_workspace_id = state.open_workspace("/workspaces/first"); + + assert_eq!(second_workspace_id, WorkspaceId::new(2)); + assert_eq!(reopened_workspace_id, first_workspace_id); + assert_eq!( + state.workspaces().active_workspace_id(), + Some(first_workspace_id) + ); + assert_eq!(state.workspaces().workspaces().len(), 2); + assert_eq!( + state.open_workspace("/workspaces/third"), + WorkspaceId::new(3) + ); + } + #[test] fn opening_tab_adds_it_to_active_pane() { let mut state = State::new(); diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 7f6dddef..2bdcb833 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { app, BrowserWindow, Menu, dialog, ipcMain } from "electron"; import fs from "node:fs"; import path from "node:path"; @@ -24,6 +24,28 @@ function registerIpcHandlers(): void { return result.canceled ? undefined : result.filePaths[0]; }); + + ipcMain.handle("kosmos:window:minimize", (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); + }); + + ipcMain.handle("kosmos:window:toggleMaximize", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + + if (!window) { + return; + } + + if (window.isMaximized()) { + window.unmaximize(); + } else { + window.maximize(); + } + }); + + ipcMain.handle("kosmos:window:close", (event) => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); } function createMainWindow(): void { @@ -34,6 +56,8 @@ function createMainWindow(): void { minWidth: 900, minHeight: 600, title: "Kosmos", + frame: false, + autoHideMenuBar: true, backgroundColor: "#111217", webPreferences: { contextIsolation: true, @@ -74,6 +98,7 @@ function validateRequest(request: KosmosIpcRequest): void { } app.whenReady().then(() => { + Menu.setApplicationMenu(null); registerIpcHandlers(); createMainWindow(); diff --git a/desktop/src/preload/preload.ts b/desktop/src/preload/preload.ts index 5b87aada..f4c45b73 100644 --- a/desktop/src/preload/preload.ts +++ b/desktop/src/preload/preload.ts @@ -12,6 +12,15 @@ const kosmos: KosmosApi = { selectWorkspaceDirectory(): Promise { return ipcRenderer.invoke("kosmos:selectWorkspaceDirectory") as Promise; }, + minimizeWindow(): Promise { + return ipcRenderer.invoke("kosmos:window:minimize") as Promise; + }, + toggleMaximizeWindow(): Promise { + return ipcRenderer.invoke("kosmos:window:toggleMaximize") as Promise; + }, + closeWindow(): Promise { + return ipcRenderer.invoke("kosmos:window:close") as Promise; + }, }; contextBridge.exposeInMainWorld("kosmos", kosmos); diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx index 3f36f9c0..bbf764be 100644 --- a/desktop/src/renderer/components/internal/header.tsx +++ b/desktop/src/renderer/components/internal/header.tsx @@ -1,15 +1,25 @@ -import { Plus } from "lucide-react"; +import { Minus, Plus, Square, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { activateWorkspace, + closeWorkspace, + closeWindow, listWorkspaces, + minimizeWindow, openWorkspace, selectWorkspaceDirectory, + toggleMaximizeWindow, type WorkspaceListSnapshot, } from "@/renderer/ipc"; import { Button } from "@/renderer/components/ui/button"; import { ButtonGroup } from "@/renderer/components/ui/button-group"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/renderer/components/ui/context-menu"; import type { WorkspaceId } from "@/shared/ipc"; export function Header() { @@ -94,15 +104,40 @@ export function Header() { } } + async function closeWorkspaceFromMenu(workspaceId: WorkspaceId): Promise { + const requestId = switchRequestId.current + 1; + const previousSnapshot = snapshot; + + switchRequestId.current = requestId; + setError(null); + setSnapshot((currentSnapshot) => closeWorkspaceLocally(currentSnapshot, workspaceId)); + + try { + const nextSnapshot = await closeWorkspace(workspaceId); + + if (switchRequestId.current === requestId) { + setSnapshot(nextSnapshot); + } + } catch (caughtError) { + if (switchRequestId.current === requestId) { + setSnapshot(previousSnapshot); + setError(errorMessage(caughtError)); + } + } + } + return ( -
+
{error ? ( -

+

{error}

) : null} - + {isLoadingWorkspaces ? ( + + void switchWorkspace(workspace.id)} + > + {workspace.name} + + } + /> + + void closeWorkspaceFromMenu(workspace.id)} + > + Close workspace + + + ); }) )} @@ -139,10 +187,67 @@ export function Header() { + +
+ + + +
); } +function closeWorkspaceLocally( + snapshot: WorkspaceListSnapshot | null, + workspaceId: WorkspaceId, +): WorkspaceListSnapshot | null { + if (!snapshot) { + return snapshot; + } + + const workspaceIndex = snapshot.workspaces.findIndex((workspace) => workspace.id === workspaceId); + + if (workspaceIndex === -1) { + return snapshot; + } + + const workspaces = snapshot.workspaces.filter((workspace) => workspace.id !== workspaceId); + const activeWorkspaceId = + snapshot.activeWorkspaceId === workspaceId + ? (workspaces[workspaceIndex]?.id ?? workspaces[workspaceIndex - 1]?.id ?? null) + : snapshot.activeWorkspaceId; + + return { ...snapshot, activeWorkspaceId, workspaces }; +} + function errorMessage(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/desktop/src/renderer/components/ui/context-menu.tsx b/desktop/src/renderer/components/ui/context-menu.tsx new file mode 100644 index 00000000..18624684 --- /dev/null +++ b/desktop/src/renderer/components/ui/context-menu.tsx @@ -0,0 +1,269 @@ +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu" + +import { cn } from "@/renderer/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) { + return +} + +function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { + return ( + + ) +} + +function ContextMenuTrigger({ + className, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + + ) +} + +function ContextMenuContent({ + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +}: ContextMenuPrimitive.Popup.Props & + Pick< + ContextMenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { + return ( + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: ContextMenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: ContextMenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: ContextMenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioGroup({ + ...props +}: ContextMenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: ContextMenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/desktop/src/renderer/ipc/transport.ts b/desktop/src/renderer/ipc/transport.ts index f5fe0f71..d5604139 100644 --- a/desktop/src/renderer/ipc/transport.ts +++ b/desktop/src/renderer/ipc/transport.ts @@ -16,6 +16,18 @@ export function selectWorkspaceDirectory(): Promise { return kosmosApi().selectWorkspaceDirectory(); } +export function minimizeWindow(): Promise { + return kosmosApi().minimizeWindow(); +} + +export function toggleMaximizeWindow(): Promise { + return kosmosApi().toggleMaximizeWindow(); +} + +export function closeWindow(): Promise { + return kosmosApi().closeWindow(); +} + function kosmosApi() { if (!window.kosmos) { throw new Error("Electron preload did not expose the Kosmos IPC API."); diff --git a/desktop/src/shared/ipc/envelope.ts b/desktop/src/shared/ipc/envelope.ts index 312763b6..72129659 100644 --- a/desktop/src/shared/ipc/envelope.ts +++ b/desktop/src/shared/ipc/envelope.ts @@ -25,4 +25,7 @@ export type KosmosApi = { request(request: KosmosIpcRequest): Promise; getSocketPath(): Promise; selectWorkspaceDirectory(): Promise; + minimizeWindow(): Promise; + toggleMaximizeWindow(): Promise; + closeWindow(): Promise; }; From 0168acc5bc13df8ec8ab00c5d46104721bef32e3 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 15:37:39 -0300 Subject: [PATCH 20/98] chore: remove title from buttongroup --- desktop/src/renderer/components/internal/header.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx index bbf764be..76401998 100644 --- a/desktop/src/renderer/components/internal/header.tsx +++ b/desktop/src/renderer/components/internal/header.tsx @@ -155,7 +155,6 @@ export function Header() { variant={isActive ? "default" : "outline"} size="sm" aria-pressed={isActive} - title={workspace.directory} onClick={() => void switchWorkspace(workspace.id)} > {workspace.name} From 89a55a79a423154cc56248f2f11d6846e4aa6496 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 16:42:30 -0300 Subject: [PATCH 21/98] feat: add tabs and panes ui --- desktop/bun.lock | 3 + desktop/package.json | 1 + desktop/src/renderer/App.tsx | 260 +++++++++- .../renderer/components/internal/header.tsx | 171 +------ .../components/internal/workspace-view.tsx | 481 ++++++++++++++++++ .../src/renderer/components/ui/resizable.tsx | 48 ++ desktop/src/renderer/components/ui/tabs.tsx | 82 +++ desktop/src/renderer/lib/errors.ts | 7 + desktop/src/shared/ipc/pane.ts | 4 +- 9 files changed, 902 insertions(+), 155 deletions(-) create mode 100644 desktop/src/renderer/components/internal/workspace-view.tsx create mode 100644 desktop/src/renderer/components/ui/resizable.tsx create mode 100644 desktop/src/renderer/components/ui/tabs.tsx create mode 100644 desktop/src/renderer/lib/errors.ts diff --git a/desktop/bun.lock b/desktop/bun.lock index 2e7754a3..1ad3d17f 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -10,6 +10,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", + "react-resizable-panels": "^4.12.0", "shadcn": "^4.12.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", @@ -633,6 +634,8 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-resizable-panels": ["react-resizable-panels@4.12.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-t/Gp57qSCxGQ52ckhz+8lM7dnuymeU95TEzl2U203qEbGkSLHrtm7US2/ANzq/zOlja3CwPTAfCDuh1unv9mfw=="], + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], diff --git a/desktop/package.json b/desktop/package.json index 55ee32ae..ff0850f9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", + "react-resizable-panels": "^4.12.0", "shadcn": "^4.12.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 98b463d1..d7c4e4f0 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -1,15 +1,263 @@ -import { Button } from "@/renderer/components/ui/button"; +import { useEffect, useRef, useState } from "react"; + import { Header } from "@/renderer/components/internal/header"; +import { + WorkspaceView, + type WorkspaceViewActions, +} from "@/renderer/components/internal/workspace-view"; +import { errorMessage } from "@/renderer/lib/errors"; +import { + activatePane, + activateTab, + activateWorkspace, + closeTab, + closeWorkspace, + listWorkspaces, + openTab, + openWorkspace, + resizeSplit, + selectWorkspaceDirectory, + splitTab, + type WorkspaceListSnapshot, +} from "@/renderer/ipc"; +import type { + PaneId, + SplitAxis, + SplitPaneId, + TabId, + WorkspaceId, + WorkspaceSnapshot, +} from "@/shared/ipc"; export function App() { + const [error, setError] = useState(null); + const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); + const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true); + const [snapshot, setSnapshot] = useState(null); + const switchRequestId = useRef(0); + + useEffect(() => { + let isMounted = true; + + listWorkspaces() + .then((nextSnapshot) => { + if (!isMounted) { + return; + } + + setSnapshot(nextSnapshot); + setError(null); + }) + .catch((caughtError: unknown) => { + if (isMounted) { + setError(errorMessage(caughtError)); + } + }) + .finally(() => { + if (isMounted) { + setIsLoadingWorkspaces(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + async function switchWorkspace(workspaceId: WorkspaceId): Promise { + if (workspaceId === snapshot?.activeWorkspaceId || isAddingWorkspace) { + return; + } + + const requestId = switchRequestId.current + 1; + const previousSnapshot = snapshot; + + switchRequestId.current = requestId; + setError(null); + setSnapshot((currentSnapshot) => + currentSnapshot ? { ...currentSnapshot, activeWorkspaceId: workspaceId } : currentSnapshot, + ); + + try { + const nextSnapshot = await activateWorkspace(workspaceId); + + if (switchRequestId.current === requestId) { + setSnapshot(nextSnapshot); + } + } catch (caughtError) { + if (switchRequestId.current === requestId) { + setSnapshot(previousSnapshot); + setError(errorMessage(caughtError)); + } + } + } + + async function addWorkspace(): Promise { + setIsAddingWorkspace(true); + setError(null); + + try { + const directory = await selectWorkspaceDirectory(); + + if (!directory) { + return; + } + + setSnapshot(await openWorkspace(directory)); + } catch (caughtError) { + setError(errorMessage(caughtError)); + } finally { + setIsAddingWorkspace(false); + } + } + + async function closeWorkspaceFromMenu(workspaceId: WorkspaceId): Promise { + const requestId = switchRequestId.current + 1; + const previousSnapshot = snapshot; + + switchRequestId.current = requestId; + setError(null); + setSnapshot((currentSnapshot) => closeWorkspaceLocally(currentSnapshot, workspaceId)); + + try { + const nextSnapshot = await closeWorkspace(workspaceId); + + if (switchRequestId.current === requestId) { + setSnapshot(nextSnapshot); + } + } catch (caughtError) { + if (switchRequestId.current === requestId) { + setSnapshot(previousSnapshot); + setError(errorMessage(caughtError)); + } + } + } + + function updateFromServer(request: () => Promise): void { + setError(null); + + void request() + .then((nextSnapshot) => { + setSnapshot(nextSnapshot); + }) + .catch((caughtError: unknown) => { + setError(errorMessage(caughtError)); + }); + } + + const activeWorkspace = activeWorkspaceFrom(snapshot); + const workspaceActions: WorkspaceViewActions = { + activatePane(paneId: PaneId) { + if (!activeWorkspace || paneId === activeWorkspace.activePaneId) { + return; + } + + updateFromServer(() => activatePane({ workspaceId: activeWorkspace.id, paneId })); + }, + activateTab(paneId: PaneId, tabId: TabId) { + if (!activeWorkspace) { + return; + } + + updateFromServer(() => activateTab({ workspaceId: activeWorkspace.id, paneId, tabId })); + }, + closeTab(paneId: PaneId, tabId: TabId) { + if (!activeWorkspace) { + return; + } + + updateFromServer(() => closeTab({ workspaceId: activeWorkspace.id, paneId, tabId })); + }, + openTab(paneId: PaneId) { + if (!activeWorkspace) { + return; + } + + updateFromServer(() => openTab({ workspaceId: activeWorkspace.id, paneId, kind: "blank" })); + }, + resizeSplit(splitId: SplitPaneId, ratio: number) { + if (!activeWorkspace) { + return; + } + + updateFromServer(() => resizeSplit({ workspaceId: activeWorkspace.id, splitId, ratio })); + }, + splitTab( + paneId: PaneId, + tabId: TabId, + targetPaneId: PaneId, + axis: SplitAxis, + newPaneFirst: boolean, + ) { + if (!activeWorkspace) { + return; + } + + updateFromServer(() => + splitTab({ + workspaceId: activeWorkspace.id, + paneId, + targetPaneId, + tabId, + axis, + newPaneFirst, + }), + ); + }, + }; + return (
-
+
void closeWorkspaceFromMenu(workspaceId)} + onOpenWorkspace={() => void addWorkspace()} + onSwitchWorkspace={(workspaceId) => void switchWorkspace(workspaceId)} + /> -
-

Hello world

- -
+ void addWorkspace()} + />
); } + +function activeWorkspaceFrom(snapshot: WorkspaceListSnapshot | null): WorkspaceSnapshot | null { + if (!snapshot?.activeWorkspaceId) { + return null; + } + + return ( + snapshot.workspaces.find((workspace) => workspace.id === snapshot.activeWorkspaceId) ?? null + ); +} + +function closeWorkspaceLocally( + snapshot: WorkspaceListSnapshot | null, + workspaceId: WorkspaceId, +): WorkspaceListSnapshot | null { + if (!snapshot) { + return snapshot; + } + + const workspaceIndex = snapshot.workspaces.findIndex((workspace) => workspace.id === workspaceId); + + if (workspaceIndex === -1) { + return snapshot; + } + + const workspaces = snapshot.workspaces.filter((workspace) => workspace.id !== workspaceId); + const activeWorkspaceId = + snapshot.activeWorkspaceId === workspaceId + ? (workspaces[workspaceIndex]?.id ?? workspaces[workspaceIndex - 1]?.id ?? null) + : snapshot.activeWorkspaceId; + + return { ...snapshot, activeWorkspaceId, workspaces }; +} diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx index 76401998..8a43d575 100644 --- a/desktop/src/renderer/components/internal/header.tsx +++ b/desktop/src/renderer/components/internal/header.tsx @@ -1,16 +1,9 @@ import { Minus, Plus, Square, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; import { - activateWorkspace, - closeWorkspace, closeWindow, - listWorkspaces, minimizeWindow, - openWorkspace, - selectWorkspaceDirectory, toggleMaximizeWindow, - type WorkspaceListSnapshot, } from "@/renderer/ipc"; import { Button } from "@/renderer/components/ui/button"; import { ButtonGroup } from "@/renderer/components/ui/button-group"; @@ -20,112 +13,27 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/renderer/components/ui/context-menu"; -import type { WorkspaceId } from "@/shared/ipc"; - -export function Header() { - const [error, setError] = useState(null); - const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); - const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true); - const [snapshot, setSnapshot] = useState(null); - const switchRequestId = useRef(0); - - useEffect(() => { - let isMounted = true; - - listWorkspaces() - .then((nextSnapshot) => { - if (!isMounted) { - return; - } - - setSnapshot(nextSnapshot); - setError(null); - }) - .catch((caughtError: unknown) => { - if (isMounted) { - setError(errorMessage(caughtError)); - } - }) - .finally(() => { - if (isMounted) { - setIsLoadingWorkspaces(false); - } - }); - - return () => { - isMounted = false; - }; - }, []); - - async function switchWorkspace(workspaceId: WorkspaceId): Promise { - if (workspaceId === snapshot?.activeWorkspaceId || isAddingWorkspace) { - return; - } - - const requestId = switchRequestId.current + 1; - const previousSnapshot = snapshot; - - switchRequestId.current = requestId; - setError(null); - setSnapshot((currentSnapshot) => - currentSnapshot ? { ...currentSnapshot, activeWorkspaceId: workspaceId } : currentSnapshot, - ); - - try { - const nextSnapshot = await activateWorkspace(workspaceId); - - if (switchRequestId.current === requestId) { - setSnapshot(nextSnapshot); - } - } catch (caughtError) { - if (switchRequestId.current === requestId) { - setSnapshot(previousSnapshot); - setError(errorMessage(caughtError)); - } - } - } - - async function addWorkspace(): Promise { - setIsAddingWorkspace(true); - setError(null); - - try { - const directory = await selectWorkspaceDirectory(); - - if (!directory) { - return; - } - - setSnapshot(await openWorkspace(directory)); - } catch (caughtError) { - setError(errorMessage(caughtError)); - } finally { - setIsAddingWorkspace(false); - } - } - - async function closeWorkspaceFromMenu(workspaceId: WorkspaceId): Promise { - const requestId = switchRequestId.current + 1; - const previousSnapshot = snapshot; - - switchRequestId.current = requestId; - setError(null); - setSnapshot((currentSnapshot) => closeWorkspaceLocally(currentSnapshot, workspaceId)); - - try { - const nextSnapshot = await closeWorkspace(workspaceId); - - if (switchRequestId.current === requestId) { - setSnapshot(nextSnapshot); - } - } catch (caughtError) { - if (switchRequestId.current === requestId) { - setSnapshot(previousSnapshot); - setError(errorMessage(caughtError)); - } - } - } - +import type { WorkspaceId, WorkspaceListSnapshot } from "@/shared/ipc"; + +type HeaderProps = { + error: string | null; + isAddingWorkspace: boolean; + isLoadingWorkspaces: boolean; + snapshot: WorkspaceListSnapshot | null; + onCloseWorkspace(workspaceId: WorkspaceId): void; + onOpenWorkspace(): void; + onSwitchWorkspace(workspaceId: WorkspaceId): void; +}; + +export function Header({ + error, + isAddingWorkspace, + isLoadingWorkspaces, + snapshot, + onCloseWorkspace, + onOpenWorkspace, + onSwitchWorkspace, +}: HeaderProps) { return (
{error ? ( @@ -155,7 +63,7 @@ export function Header() { variant={isActive ? "default" : "outline"} size="sm" aria-pressed={isActive} - onClick={() => void switchWorkspace(workspace.id)} + onClick={() => onSwitchWorkspace(workspace.id)} > {workspace.name} @@ -164,7 +72,7 @@ export function Header() { void closeWorkspaceFromMenu(workspace.id)} + onClick={() => onCloseWorkspace(workspace.id)} > Close workspace @@ -181,7 +89,7 @@ export function Header() { aria-label="Open workspace" title="Open workspace" disabled={isLoadingWorkspaces || isAddingWorkspace} - onClick={() => void addWorkspace()} + onClick={onOpenWorkspace} > @@ -223,34 +131,3 @@ export function Header() {
); } - -function closeWorkspaceLocally( - snapshot: WorkspaceListSnapshot | null, - workspaceId: WorkspaceId, -): WorkspaceListSnapshot | null { - if (!snapshot) { - return snapshot; - } - - const workspaceIndex = snapshot.workspaces.findIndex((workspace) => workspace.id === workspaceId); - - if (workspaceIndex === -1) { - return snapshot; - } - - const workspaces = snapshot.workspaces.filter((workspace) => workspace.id !== workspaceId); - const activeWorkspaceId = - snapshot.activeWorkspaceId === workspaceId - ? (workspaces[workspaceIndex]?.id ?? workspaces[workspaceIndex - 1]?.id ?? null) - : snapshot.activeWorkspaceId; - - return { ...snapshot, activeWorkspaceId, workspaces }; -} - -function errorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - return "Unable to communicate with the Kosmos server."; -} diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx new file mode 100644 index 00000000..ee7abcdb --- /dev/null +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -0,0 +1,481 @@ +import { useState, type DragEvent } from "react"; +import { + File, + FileText, + FolderTree, + GitBranch, + Plus, + Search, + Settings, + Terminal, + X, + type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/renderer/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/renderer/components/ui/context-menu"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/renderer/components/ui/resizable"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/renderer/components/ui/tabs"; +import { cn } from "@/renderer/lib/utils"; +import type { + PaneId, + PaneNodeSnapshot, + PaneSnapshot, + SplitAxis, + SplitPaneId, + TabId, + TabKind, + TabSnapshot, + WorkspaceSnapshot, +} from "@/shared/ipc"; + +export type WorkspaceViewActions = { + activatePane(paneId: PaneId): void; + activateTab(paneId: PaneId, tabId: TabId): void; + closeTab(paneId: PaneId, tabId: TabId): void; + openTab(paneId: PaneId): void; + resizeSplit(splitId: SplitPaneId, ratio: number): void; + splitTab( + paneId: PaneId, + tabId: TabId, + targetPaneId: PaneId, + axis: SplitAxis, + newPaneFirst: boolean, + ): void; +}; + +type WorkspaceViewProps = { + actions: WorkspaceViewActions; + isAddingWorkspace: boolean; + isLoading: boolean; + workspace: WorkspaceSnapshot | null; + onOpenWorkspace(): void; +}; + +type DropEdge = "left" | "right" | "top" | "bottom"; + +type DraggedTab = { + paneId: PaneId; + tabId: TabId; +}; + +const TAB_DRAG_MIME = "application/x-kosmos-tab"; + +const TAB_KIND_LABEL: Record = { + blank: "Blank", + editor: "Editor", + fileTree: "File Tree", + git: "Git", + search: "Search", + settings: "Settings", + terminal: "Terminal", +}; + +const TAB_KIND_ICON: Record = { + blank: File, + editor: FileText, + fileTree: FolderTree, + git: GitBranch, + search: Search, + settings: Settings, + terminal: Terminal, +}; + +export function WorkspaceView({ + actions, + isAddingWorkspace, + isLoading, + workspace, + onOpenWorkspace, +}: WorkspaceViewProps) { + if (isLoading) { + return ( +
+

Loading workspace state...

+
+ ); + } + + if (!workspace) { + return ( +
+
+
+

No workspace open

+

+ Open a workspace to create tabs and panes. +

+
+ +
+
+ ); + } + + return ( +
+ +
+ ); +} + +function PaneNodeView({ + node, + workspace, + actions, + isRoot = false, +}: { + node: PaneNodeSnapshot; + workspace: WorkspaceSnapshot; + actions: WorkspaceViewActions; + isRoot?: boolean; +}) { + if (node.type === "leaf") { + return ; + } + + const firstPanelId = `split-${node.id}-first`; + const secondPanelId = `split-${node.id}-second`; + const firstSize = ratioToPercent(node.ratio); + const secondSize = 100 - firstSize; + + return ( + { + if (!meta.isUserInteraction) { + return; + } + + const firstLayoutSize = layout[firstPanelId]; + const secondLayoutSize = layout[secondPanelId]; + if (firstLayoutSize === undefined || secondLayoutSize === undefined) { + return; + } + + const nextRatio = firstLayoutSize / (firstLayoutSize + secondLayoutSize); + if (!Number.isFinite(nextRatio) || Math.abs(nextRatio - node.ratio) < 0.005) { + return; + } + + actions.resizeSplit(node.id, nextRatio); + }} + > + + + + + + + + + ); +} + +function PaneLeaf({ + pane, + workspace, + actions, + isRoot, +}: { + pane: PaneSnapshot; + workspace: WorkspaceSnapshot; + actions: WorkspaceViewActions; + isRoot: boolean; +}) { + const [dropEdge, setDropEdge] = useState(null); + return ( +
{ + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return; + } + + setDropEdge(null); + }} + onDragOver={(event) => { + if (!hasDraggedTab(event)) { + return; + } + + const edge = edgeFromDragEvent(event, event.currentTarget); + setDropEdge(edge); + + if (!edge) { + event.dataTransfer.dropEffect = "none"; + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + const draggedTab = readDraggedTab(event); + const edge = edgeFromDragEvent(event, event.currentTarget); + setDropEdge(null); + + if (!draggedTab || !edge) { + return; + } + + const split = splitDetailsFromEdge(edge); + event.preventDefault(); + actions.splitTab( + draggedTab.paneId, + draggedTab.tabId, + pane.id, + split.axis, + split.newPaneFirst, + ); + }} + > + activateTabFromValue(value, pane, actions)} + > +
+ + {pane.tabs.map((tab) => ( + + ))} + + + +
+ + {pane.tabs.map((tab) => ( + + actions.activatePane(pane.id)} /> + + ))} +
+ + +
+ ); +} + +function TabTrigger({ + pane, + tab, + actions, +}: { + pane: PaneSnapshot; + tab: TabSnapshot; + actions: WorkspaceViewActions; +}) { + const TabIcon = TAB_KIND_ICON[tab.kind]; + + return ( + + } + className="group/tab max-w-52 flex-none cursor-default justify-start px-2 text-xs" + title={`${tab.title}. Drag to a pane edge to split.`} + onDragStart={(event) => writeDraggedTab(event, pane.id, tab.id, tab.title)} + > + + ); +} + +function TabBody({ tab, onActivatePane }: { tab: TabSnapshot; onActivatePane(): void }) { + return ( +
+

+ {tab.title} +

+
+ ); +} + +function DropIndicator({ edge }: { edge: DropEdge | null }) { + if (!edge) { + return null; + } + + return ( +
+
+
+ ); +} + +function activateTabFromValue( + value: unknown, + pane: PaneSnapshot, + actions: WorkspaceViewActions, +): void { + const tabId = Number(value); + + if (!Number.isSafeInteger(tabId) || tabId === pane.activeTabId) { + return; + } + + if (!pane.tabs.some((tab) => tab.id === tabId)) { + return; + } + + actions.activateTab(pane.id, tabId); +} + +function writeDraggedTab( + event: DragEvent, + paneId: PaneId, + tabId: TabId, + title: string, +): void { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(TAB_DRAG_MIME, JSON.stringify({ paneId, tabId })); + event.dataTransfer.setData("text/plain", title); +} + +function readDraggedTab(event: DragEvent): DraggedTab | null { + const payload = event.dataTransfer.getData(TAB_DRAG_MIME); + if (!payload) { + return null; + } + + let value: unknown; + try { + value = JSON.parse(payload); + } catch { + return null; + } + + if (!value || typeof value !== "object") { + return null; + } + + const { paneId, tabId } = value as Partial; + if ( + typeof paneId !== "number" || + typeof tabId !== "number" || + !Number.isSafeInteger(paneId) || + !Number.isSafeInteger(tabId) + ) { + return null; + } + + return { paneId, tabId }; +} + +function hasDraggedTab(event: DragEvent): boolean { + for (let index = 0; index < event.dataTransfer.types.length; index += 1) { + if (event.dataTransfer.types[index] === TAB_DRAG_MIME) { + return true; + } + } + + return false; +} + +function edgeFromDragEvent(event: DragEvent, element: HTMLElement): DropEdge | null { + const rect = element.getBoundingClientRect(); + const threshold = Math.min(96, Math.max(40, Math.min(rect.width, rect.height) * 0.28)); + const distances: Array<[DropEdge, number]> = [ + ["left", event.clientX - rect.left], + ["right", rect.right - event.clientX], + ["top", event.clientY - rect.top], + ["bottom", rect.bottom - event.clientY], + ]; + const [edge, distance] = distances.reduce((closest, current) => + current[1] < closest[1] ? current : closest, + ); + + return distance <= threshold ? edge : null; +} + +function splitDetailsFromEdge(edge: DropEdge): { axis: SplitAxis; newPaneFirst: boolean } { + return { + axis: edge === "left" || edge === "right" ? "horizontal" : "vertical", + newPaneFirst: edge === "left" || edge === "top", + }; +} + +function tabValue(tabId: TabId): string { + return String(tabId); +} + +function ratioToPercent(ratio: number): number { + return Math.min(Math.max(ratio * 100, 1), 99); +} diff --git a/desktop/src/renderer/components/ui/resizable.tsx b/desktop/src/renderer/components/ui/resizable.tsx new file mode 100644 index 00000000..e9e4885a --- /dev/null +++ b/desktop/src/renderer/components/ui/resizable.tsx @@ -0,0 +1,48 @@ +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/renderer/lib/utils" + +function ResizablePanelGroup({ + className, + ...props +}: ResizablePrimitive.GroupProps) { + return ( + + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ )} + + ) +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/desktop/src/renderer/components/ui/tabs.tsx b/desktop/src/renderer/components/ui/tabs.tsx new file mode 100644 index 00000000..345ab78a --- /dev/null +++ b/desktop/src/renderer/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/renderer/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/desktop/src/renderer/lib/errors.ts b/desktop/src/renderer/lib/errors.ts new file mode 100644 index 00000000..be28ef0a --- /dev/null +++ b/desktop/src/renderer/lib/errors.ts @@ -0,0 +1,7 @@ +export function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return "Unable to communicate with the Kosmos server."; +} diff --git a/desktop/src/shared/ipc/pane.ts b/desktop/src/shared/ipc/pane.ts index bff380ff..1256d428 100644 --- a/desktop/src/shared/ipc/pane.ts +++ b/desktop/src/shared/ipc/pane.ts @@ -1,4 +1,4 @@ -import type { PaneId, SplitPaneId, WorkspaceId } from "./ids"; +import type { PaneId, SplitPaneId, TabId, WorkspaceId } from "./ids"; import type { TabSnapshot } from "./tab"; export type SplitAxis = "horizontal" | "vertical"; @@ -45,6 +45,6 @@ export type PaneNodeSnapshot = export type PaneSnapshot = { id: PaneId; - activeTabId: number; + activeTabId: TabId; tabs: TabSnapshot[]; }; From 827b877ddfa305fe51fef770d3a5c5e25e50e40d Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 17:51:11 -0300 Subject: [PATCH 22/98] fix: stabilize pane resizing --- desktop/src/renderer/App.tsx | 7 ++++++- .../src/renderer/components/internal/workspace-view.tsx | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index d7c4e4f0..f88054cb 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -180,7 +180,12 @@ export function App() { return; } - updateFromServer(() => resizeSplit({ workspaceId: activeWorkspace.id, splitId, ratio })); + setError(null); + void resizeSplit({ workspaceId: activeWorkspace.id, splitId, ratio }).catch( + (caughtError: unknown) => { + setError(errorMessage(caughtError)); + }, + ); }, splitTab( paneId: PaneId, diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index ee7abcdb..dce99c10 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -69,6 +69,7 @@ type DraggedTab = { }; const TAB_DRAG_MIME = "application/x-kosmos-tab"; +const MIN_PANE_SIZE = "16rem"; const TAB_KIND_LABEL: Record = { blank: "Blank", @@ -153,7 +154,7 @@ function PaneNodeView({ return ( { @@ -168,18 +169,18 @@ function PaneNodeView({ } const nextRatio = firstLayoutSize / (firstLayoutSize + secondLayoutSize); - if (!Number.isFinite(nextRatio) || Math.abs(nextRatio - node.ratio) < 0.005) { + if (!Number.isFinite(nextRatio) || nextRatio === node.ratio) { return; } actions.resizeSplit(node.id, nextRatio); }} > - + - + From 6f441679e761a9ea261f98a74a66fb54926e10ef Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 18:00:19 -0300 Subject: [PATCH 23/98] chore: update empty workspace copy --- desktop/src/renderer/components/internal/workspace-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index dce99c10..31a65a72 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -111,9 +111,9 @@ export function WorkspaceView({
-

No workspace open

+

Welcome to Kosmos

- Open a workspace to create tabs and panes. + Open a workspace to start exploring your project.

+ ); + })} +
+
+ ); +} diff --git a/desktop/src/renderer/components/tabs/index.ts b/desktop/src/renderer/components/tabs/index.ts new file mode 100644 index 00000000..6f3851f9 --- /dev/null +++ b/desktop/src/renderer/components/tabs/index.ts @@ -0,0 +1 @@ +export { renderTabContent, tabKindIcon } from "./registry"; diff --git a/desktop/src/renderer/components/tabs/placeholder.tsx b/desktop/src/renderer/components/tabs/placeholder.tsx new file mode 100644 index 00000000..0207c35e --- /dev/null +++ b/desktop/src/renderer/components/tabs/placeholder.tsx @@ -0,0 +1,17 @@ +type PlaceholderTabProps = { + title: string; + onActivatePane(): void; +}; + +export function PlaceholderTab({ title, onActivatePane }: PlaceholderTabProps) { + return ( +
+

+ {title} +

+
+ ); +} diff --git a/desktop/src/renderer/components/tabs/registry.tsx b/desktop/src/renderer/components/tabs/registry.tsx new file mode 100644 index 00000000..3518332e --- /dev/null +++ b/desktop/src/renderer/components/tabs/registry.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from "react"; +import { + File, + FileText, + FolderTree, + GitBranch, + Search, + Settings, + Terminal, + type LucideIcon, +} from "lucide-react"; + +import type { PaneId, TabKind, TabSnapshot } from "@/shared/ipc"; + +import { BlankTab, type BlankTabOption } from "./blank"; +import { PlaceholderTab } from "./placeholder"; + +type TabContentProps = { + paneId: PaneId; + tab: TabSnapshot; + onActivatePane(): void; + onSetTabKind(kind: TabKind): void; +}; + +type TabDefinition = { + icon: LucideIcon; + label: string; + showInBlankPicker: boolean; + render(props: TabContentProps): ReactNode; +}; + +const TAB_KIND_ORDER: TabKind[] = [ + "blank", + "fileTree", + "editor", + "git", + "search", + "terminal", + "settings", +]; + +const TAB_DEFINITIONS: Record = { + blank: { + icon: File, + label: "Blank", + showInBlankPicker: false, + render: ({ onActivatePane, onSetTabKind }) => ( + + ), + }, + fileTree: placeholderTabDefinition("File Tree", FolderTree, true), + editor: placeholderTabDefinition("Editor", FileText, false), + git: placeholderTabDefinition("Git", GitBranch, true), + search: placeholderTabDefinition("Search", Search, true), + terminal: placeholderTabDefinition("Terminal", Terminal, true), + settings: placeholderTabDefinition("Settings", Settings, true), +}; + +const BLANK_TAB_OPTIONS: BlankTabOption[] = TAB_KIND_ORDER.filter( + (kind) => TAB_DEFINITIONS[kind].showInBlankPicker, +).map((kind) => ({ + icon: TAB_DEFINITIONS[kind].icon, + kind, + label: TAB_DEFINITIONS[kind].label, +})); + +export function renderTabContent(props: TabContentProps): ReactNode { + return TAB_DEFINITIONS[props.tab.kind].render(props); +} + +export function tabKindIcon(kind: TabKind): LucideIcon { + return TAB_DEFINITIONS[kind].icon; +} + +function placeholderTabDefinition( + label: string, + icon: LucideIcon, + showInBlankPicker: boolean, +): TabDefinition { + return { + icon, + label, + showInBlankPicker, + render: ({ tab, onActivatePane }) => ( + + ), + }; +} From 39181d6bcd8a6ed319ab8ad134679d5efee3e190 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Sun, 5 Jul 2026 22:22:22 -0300 Subject: [PATCH 27/98] feat: add workspace state store --- core/src/state.rs | 21 ++ desktop/bun.lock | 3 + desktop/package.json | 3 +- desktop/src/renderer/App.tsx | 275 +----------------- .../renderer/components/internal/header.tsx | 34 +-- .../components/internal/workspace-view.tsx | 117 ++++---- .../src/renderer/lib/workspace-snapshot.ts | 164 +++++++++++ desktop/src/renderer/stores/index.ts | 1 + .../src/renderer/stores/workspace-store.ts | 252 ++++++++++++++++ 9 files changed, 513 insertions(+), 357 deletions(-) create mode 100644 desktop/src/renderer/lib/workspace-snapshot.ts create mode 100644 desktop/src/renderer/stores/index.ts create mode 100644 desktop/src/renderer/stores/workspace-store.ts diff --git a/core/src/state.rs b/core/src/state.rs index 9e4f2ae2..7acfe10e 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -611,4 +611,25 @@ mod tests { assert_eq!(split.ratio(), 0.7); } + + #[test] + fn resized_split_survives_workspace_switches() { + let mut state = State::new(); + let first_workspace_id = state.open_workspace("/workspaces/first"); + assert!(state.split_pane(Some(first_workspace_id), None, SplitAxis::Horizontal, false,)); + assert!(state.resize_split(Some(first_workspace_id), SplitPaneId::new(1), 0.7)); + + state.open_workspace("/workspaces/second"); + assert!(state.activate_workspace(first_workspace_id)); + + let workspace = state + .workspaces() + .active_workspace() + .expect("first workspace should be active again"); + let crate::tree::PaneNode::Split(split) = workspace.root() else { + panic!("workspace root should be split"); + }; + + assert_eq!(split.ratio(), 0.7); + } } diff --git a/desktop/bun.lock b/desktop/bun.lock index 1ad3d17f..90acf712 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -14,6 +14,7 @@ "shadcn": "^4.12.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "zustand": "^5.0.14", }, "devDependencies": { "@tailwindcss/vite": "^4.3.2", @@ -772,6 +773,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/desktop/package.json b/desktop/package.json index ff0850f9..a4aaf516 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -34,6 +34,7 @@ "react-resizable-panels": "^4.12.0", "shadcn": "^4.12.0", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.14" } } diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 168d3d8a..2cac4bf3 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -1,279 +1,20 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; import { Header } from "@/renderer/components/internal/header"; -import { - WorkspaceView, - type WorkspaceViewActions, -} from "@/renderer/components/internal/workspace-view"; -import { errorMessage } from "@/renderer/lib/errors"; -import { - activatePane, - activateTab, - activateWorkspace, - closeTab, - closeWorkspace, - listWorkspaces, - openTab, - openWorkspace, - resizeSplit, - setTabKind, - selectWorkspaceDirectory, - splitTab, - type WorkspaceListSnapshot, -} from "@/renderer/ipc"; -import type { - PaneId, - SplitAxis, - SplitPaneId, - TabId, - TabKind, - WorkspaceId, - WorkspaceSnapshot, -} from "@/shared/ipc"; +import { WorkspaceView } from "@/renderer/components/internal/workspace-view"; +import { useWorkspaceStore } from "@/renderer/stores"; export function App() { - const [error, setError] = useState(null); - const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); - const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(true); - const [snapshot, setSnapshot] = useState(null); - const switchRequestId = useRef(0); + const initializeWorkspaces = useWorkspaceStore((state) => state.initializeWorkspaces); useEffect(() => { - let isMounted = true; - - listWorkspaces() - .then((nextSnapshot) => { - if (!isMounted) { - return; - } - - setSnapshot(nextSnapshot); - setError(null); - }) - .catch((caughtError: unknown) => { - if (isMounted) { - setError(errorMessage(caughtError)); - } - }) - .finally(() => { - if (isMounted) { - setIsLoadingWorkspaces(false); - } - }); - - return () => { - isMounted = false; - }; - }, []); - - async function switchWorkspace(workspaceId: WorkspaceId): Promise { - if (workspaceId === snapshot?.activeWorkspaceId || isAddingWorkspace) { - return; - } - - const requestId = switchRequestId.current + 1; - const previousSnapshot = snapshot; - - switchRequestId.current = requestId; - setError(null); - setSnapshot((currentSnapshot) => - currentSnapshot ? { ...currentSnapshot, activeWorkspaceId: workspaceId } : currentSnapshot, - ); - - try { - const nextSnapshot = await activateWorkspace(workspaceId); - - if (switchRequestId.current === requestId) { - setSnapshot(nextSnapshot); - } - } catch (caughtError) { - if (switchRequestId.current === requestId) { - setSnapshot(previousSnapshot); - setError(errorMessage(caughtError)); - } - } - } - - async function addWorkspace(): Promise { - setIsAddingWorkspace(true); - setError(null); - - try { - const directory = await selectWorkspaceDirectory(); - - if (!directory) { - return; - } - - setSnapshot(await openWorkspace(directory)); - } catch (caughtError) { - setError(errorMessage(caughtError)); - } finally { - setIsAddingWorkspace(false); - } - } - - async function closeWorkspaceFromMenu(workspaceId: WorkspaceId): Promise { - const requestId = switchRequestId.current + 1; - const previousSnapshot = snapshot; - - switchRequestId.current = requestId; - setError(null); - setSnapshot((currentSnapshot) => closeWorkspaceLocally(currentSnapshot, workspaceId)); - - try { - const nextSnapshot = await closeWorkspace(workspaceId); - - if (switchRequestId.current === requestId) { - setSnapshot(nextSnapshot); - } - } catch (caughtError) { - if (switchRequestId.current === requestId) { - setSnapshot(previousSnapshot); - setError(errorMessage(caughtError)); - } - } - } - - function updateFromServer(request: () => Promise): void { - setError(null); - - void request() - .then((nextSnapshot) => { - setSnapshot(nextSnapshot); - }) - .catch((caughtError: unknown) => { - setError(errorMessage(caughtError)); - }); - } - - const activeWorkspace = activeWorkspaceFrom(snapshot); - const workspaceActions: WorkspaceViewActions = { - activatePane(paneId: PaneId) { - if (!activeWorkspace || paneId === activeWorkspace.activePaneId) { - return; - } - - updateFromServer(() => activatePane({ workspaceId: activeWorkspace.id, paneId })); - }, - activateTab(paneId: PaneId, tabId: TabId) { - if (!activeWorkspace) { - return; - } - - updateFromServer(() => activateTab({ workspaceId: activeWorkspace.id, paneId, tabId })); - }, - closeTab(paneId: PaneId, tabId: TabId) { - if (!activeWorkspace) { - return; - } - - updateFromServer(() => closeTab({ workspaceId: activeWorkspace.id, paneId, tabId })); - }, - openTab(paneId: PaneId) { - if (!activeWorkspace) { - return; - } - - updateFromServer(() => openTab({ workspaceId: activeWorkspace.id, paneId, kind: "blank" })); - }, - resizeSplit(splitId: SplitPaneId, ratio: number) { - if (!activeWorkspace) { - return; - } - - setError(null); - void resizeSplit({ workspaceId: activeWorkspace.id, splitId, ratio }).catch( - (caughtError: unknown) => { - setError(errorMessage(caughtError)); - }, - ); - }, - setTabKind(paneId: PaneId, tabId: TabId, kind: TabKind) { - if (!activeWorkspace) { - return; - } - - updateFromServer(() => - setTabKind({ workspaceId: activeWorkspace.id, paneId, tabId, kind }), - ); - }, - splitTab( - paneId: PaneId, - tabId: TabId, - targetPaneId: PaneId, - axis: SplitAxis, - newPaneFirst: boolean, - ) { - if (!activeWorkspace) { - return; - } - - updateFromServer(() => - splitTab({ - workspaceId: activeWorkspace.id, - paneId, - targetPaneId, - tabId, - axis, - newPaneFirst, - }), - ); - }, - }; + void initializeWorkspaces(); + }, [initializeWorkspaces]); return (
-
void closeWorkspaceFromMenu(workspaceId)} - onOpenWorkspace={() => void addWorkspace()} - onSwitchWorkspace={(workspaceId) => void switchWorkspace(workspaceId)} - /> - - void addWorkspace()} - /> +
+
); } - -function activeWorkspaceFrom(snapshot: WorkspaceListSnapshot | null): WorkspaceSnapshot | null { - if (!snapshot?.activeWorkspaceId) { - return null; - } - - return ( - snapshot.workspaces.find((workspace) => workspace.id === snapshot.activeWorkspaceId) ?? null - ); -} - -function closeWorkspaceLocally( - snapshot: WorkspaceListSnapshot | null, - workspaceId: WorkspaceId, -): WorkspaceListSnapshot | null { - if (!snapshot) { - return snapshot; - } - - const workspaceIndex = snapshot.workspaces.findIndex((workspace) => workspace.id === workspaceId); - - if (workspaceIndex === -1) { - return snapshot; - } - - const workspaces = snapshot.workspaces.filter((workspace) => workspace.id !== workspaceId); - const activeWorkspaceId = - snapshot.activeWorkspaceId === workspaceId - ? (workspaces[workspaceIndex]?.id ?? workspaces[workspaceIndex - 1]?.id ?? null) - : snapshot.activeWorkspaceId; - - return { ...snapshot, activeWorkspaceId, workspaces }; -} diff --git a/desktop/src/renderer/components/internal/header.tsx b/desktop/src/renderer/components/internal/header.tsx index f06d1551..7d70e333 100644 --- a/desktop/src/renderer/components/internal/header.tsx +++ b/desktop/src/renderer/components/internal/header.tsx @@ -5,6 +5,7 @@ import { minimizeWindow, toggleMaximizeWindow, } from "@/renderer/ipc"; +import { useWorkspaceStore } from "@/renderer/stores"; import { Button } from "@/renderer/components/ui/button"; import { ButtonGroup } from "@/renderer/components/ui/button-group"; import { @@ -13,27 +14,16 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/renderer/components/ui/context-menu"; -import type { WorkspaceId, WorkspaceListSnapshot } from "@/shared/ipc"; -type HeaderProps = { - error: string | null; - isAddingWorkspace: boolean; - isLoadingWorkspaces: boolean; - snapshot: WorkspaceListSnapshot | null; - onCloseWorkspace(workspaceId: WorkspaceId): void; - onOpenWorkspace(): void; - onSwitchWorkspace(workspaceId: WorkspaceId): void; -}; +export function Header() { + const addWorkspace = useWorkspaceStore((state) => state.addWorkspace); + const closeWorkspace = useWorkspaceStore((state) => state.closeWorkspace); + const error = useWorkspaceStore((state) => state.error); + const isAddingWorkspace = useWorkspaceStore((state) => state.isAddingWorkspace); + const isLoadingWorkspaces = useWorkspaceStore((state) => state.isLoadingWorkspaces); + const snapshot = useWorkspaceStore((state) => state.snapshot); + const switchWorkspace = useWorkspaceStore((state) => state.switchWorkspace); -export function Header({ - error, - isAddingWorkspace, - isLoadingWorkspaces, - snapshot, - onCloseWorkspace, - onOpenWorkspace, - onSwitchWorkspace, -}: HeaderProps) { return (
{error ? ( @@ -63,7 +53,7 @@ export function Header({ variant={isActive ? "default" : "outline"} size="sm" aria-pressed={isActive} - onClick={() => onSwitchWorkspace(workspace.id)} + onClick={() => void switchWorkspace(workspace.id)} > {workspace.name} @@ -72,7 +62,7 @@ export function Header({ onCloseWorkspace(workspace.id)} + onClick={() => void closeWorkspace(workspace.id)} > Close workspace @@ -88,7 +78,7 @@ export function Header({ size="icon-sm" aria-label="Open workspace" disabled={isLoadingWorkspaces || isAddingWorkspace} - onClick={onOpenWorkspace} + onClick={() => void addWorkspace()} > diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index 24b62deb..ced53635 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -9,6 +9,7 @@ import { ContextMenuTrigger, } from "@/renderer/components/ui/context-menu"; import { renderTabContent, tabKindIcon } from "@/renderer/components/tabs"; +import { activeWorkspaceFrom } from "@/renderer/lib/workspace-snapshot"; import { ResizableHandle, ResizablePanel, @@ -16,42 +17,17 @@ import { } from "@/renderer/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/renderer/components/ui/tabs"; import { cn } from "@/renderer/lib/utils"; +import { useWorkspaceStore } from "@/renderer/stores"; import type { PaneId, PaneNodeSnapshot, PaneSnapshot, SplitAxis, - SplitPaneId, TabId, - TabKind, TabSnapshot, - WorkspaceSnapshot, + WorkspaceId, } from "@/shared/ipc"; -export type WorkspaceViewActions = { - activatePane(paneId: PaneId): void; - activateTab(paneId: PaneId, tabId: TabId): void; - closeTab(paneId: PaneId, tabId: TabId): void; - openTab(paneId: PaneId): void; - resizeSplit(splitId: SplitPaneId, ratio: number): void; - setTabKind(paneId: PaneId, tabId: TabId, kind: TabKind): void; - splitTab( - paneId: PaneId, - tabId: TabId, - targetPaneId: PaneId, - axis: SplitAxis, - newPaneFirst: boolean, - ): void; -}; - -type WorkspaceViewProps = { - actions: WorkspaceViewActions; - isAddingWorkspace: boolean; - isLoading: boolean; - workspace: WorkspaceSnapshot | null; - onOpenWorkspace(): void; -}; - type DropEdge = "left" | "right" | "top" | "bottom"; type DraggedTab = { @@ -62,14 +38,13 @@ type DraggedTab = { const TAB_DRAG_MIME = "application/x-kosmos-tab"; const MIN_PANE_SIZE_REM = 16; -export function WorkspaceView({ - actions, - isAddingWorkspace, - isLoading, - workspace, - onOpenWorkspace, -}: WorkspaceViewProps) { - if (isLoading) { +export function WorkspaceView() { + const addWorkspace = useWorkspaceStore((state) => state.addWorkspace); + const isAddingWorkspace = useWorkspaceStore((state) => state.isAddingWorkspace); + const isLoadingWorkspaces = useWorkspaceStore((state) => state.isLoadingWorkspaces); + const workspace = useWorkspaceStore((state) => activeWorkspaceFrom(state.snapshot)); + + if (isLoadingWorkspaces) { return (

Loading workspace state...

@@ -87,7 +62,7 @@ export function WorkspaceView({ Open a workspace to start exploring your project.

- @@ -98,24 +73,24 @@ export function WorkspaceView({ return (
- +
); } function PaneNodeView({ node, - workspace, - actions, + workspaceId, isRoot = false, }: { node: PaneNodeSnapshot; - workspace: WorkspaceSnapshot; - actions: WorkspaceViewActions; + workspaceId: WorkspaceId; isRoot?: boolean; }) { + const resizeSplit = useWorkspaceStore((state) => state.resizeSplit); + if (node.type === "leaf") { - return ; + return ; } const firstPanelId = `split-${node.id}-first`; @@ -125,8 +100,13 @@ function PaneNodeView({ return ( { if (!meta.isUserInteraction) { @@ -144,23 +124,23 @@ function PaneNodeView({ return; } - actions.resizeSplit(node.id, nextRatio); + resizeSplit(node.id, nextRatio); }} > - + - + ); @@ -168,16 +148,17 @@ function PaneNodeView({ function PaneLeaf({ pane, - workspace, - actions, isRoot, }: { pane: PaneSnapshot; - workspace: WorkspaceSnapshot; - actions: WorkspaceViewActions; isRoot: boolean; }) { const [dropEdge, setDropEdge] = useState(null); + const activatePane = useWorkspaceStore((state) => state.activatePane); + const activateTab = useWorkspaceStore((state) => state.activateTab); + const openTab = useWorkspaceStore((state) => state.openTab); + const splitTab = useWorkspaceStore((state) => state.splitTab); + return (
activateTabFromValue(value, pane, actions)} + onValueChange={(value) => activateTabFromValue(value, pane, activateTab)} >
{pane.tabs.map((tab) => ( - + ))} @@ -249,7 +230,7 @@ function PaneLeaf({ size="icon-sm" className="shrink-0" aria-label="Open tab" - onClick={() => actions.openTab(pane.id)} + onClick={() => openTab(pane.id)} > @@ -260,8 +241,7 @@ function PaneLeaf({ actions.activatePane(pane.id)} + onActivatePane={() => activatePane(pane.id)} /> ))} @@ -275,13 +255,12 @@ function PaneLeaf({ function TabTrigger({ pane, tab, - actions, }: { pane: PaneSnapshot; tab: TabSnapshot; - actions: WorkspaceViewActions; }) { const TabIcon = tabKindIcon(tab.kind); + const closeTab = useWorkspaceStore((state) => state.closeTab); return ( @@ -312,7 +291,7 @@ function TabTrigger({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - actions.closeTab(pane.id, tab.id); + closeTab(pane.id, tab.id); }} > @@ -321,7 +300,7 @@ function TabTrigger({ } /> - actions.closeTab(pane.id, tab.id)}> + closeTab(pane.id, tab.id)}> Close tab @@ -332,19 +311,19 @@ function TabTrigger({ function TabBody({ paneId, tab, - actions, onActivatePane, }: { paneId: PaneId; tab: TabSnapshot; - actions: WorkspaceViewActions; onActivatePane(): void; }) { + const setTabKind = useWorkspaceStore((state) => state.setTabKind); + return renderTabContent({ paneId, tab, onActivatePane, - onSetTabKind: (kind) => actions.setTabKind(paneId, tab.id, kind), + onSetTabKind: (kind) => setTabKind(paneId, tab.id, kind), }); } @@ -371,7 +350,7 @@ function DropIndicator({ edge }: { edge: DropEdge | null }) { function activateTabFromValue( value: unknown, pane: PaneSnapshot, - actions: WorkspaceViewActions, + activateTab: (paneId: PaneId, tabId: TabId) => void, ): void { const tabId = Number(value); @@ -383,7 +362,7 @@ function activateTabFromValue( return; } - actions.activateTab(pane.id, tabId); + activateTab(pane.id, tabId); } function writeDraggedTab( @@ -468,6 +447,10 @@ function ratioToPercent(ratio: number): number { return Math.min(Math.max(ratio * 100, 1), 99); } +function percentSize(value: number): string { + return `${value}%`; +} + function minimumNodeSize(node: PaneNodeSnapshot, axis: SplitAxis): string { return `${minimumNodeSizeRem(node, axis)}rem`; } diff --git a/desktop/src/renderer/lib/workspace-snapshot.ts b/desktop/src/renderer/lib/workspace-snapshot.ts new file mode 100644 index 00000000..3e67df52 --- /dev/null +++ b/desktop/src/renderer/lib/workspace-snapshot.ts @@ -0,0 +1,164 @@ +import type { + PaneNodeSnapshot, + SplitPaneId, + WorkspaceId, + WorkspaceListSnapshot, + WorkspaceSnapshot, +} from "@/shared/ipc"; + +export function activeWorkspaceFrom( + snapshot: WorkspaceListSnapshot | null, +): WorkspaceSnapshot | null { + if (!snapshot?.activeWorkspaceId) { + return null; + } + + return ( + snapshot.workspaces.find((workspace) => workspace.id === snapshot.activeWorkspaceId) ?? null + ); +} + +export function closeWorkspaceLocally( + snapshot: WorkspaceListSnapshot | null, + workspaceId: WorkspaceId, +): WorkspaceListSnapshot | null { + if (!snapshot) { + return snapshot; + } + + const workspaceIndex = snapshot.workspaces.findIndex((workspace) => workspace.id === workspaceId); + + if (workspaceIndex === -1) { + return snapshot; + } + + const workspaces = snapshot.workspaces.filter((workspace) => workspace.id !== workspaceId); + const activeWorkspaceId = + snapshot.activeWorkspaceId === workspaceId + ? (workspaces[workspaceIndex]?.id ?? workspaces[workspaceIndex - 1]?.id ?? null) + : snapshot.activeWorkspaceId; + + return { ...snapshot, activeWorkspaceId, workspaces }; +} + +export function resizeSplitLocally( + snapshot: WorkspaceListSnapshot | null, + workspaceId: WorkspaceId, + splitId: SplitPaneId, + ratio: number, +): WorkspaceListSnapshot | null { + if (!snapshot || !isValidSplitRatio(ratio)) { + return snapshot; + } + + let updated = false; + const workspaces = snapshot.workspaces.map((workspace) => { + if (workspace.id !== workspaceId) { + return workspace; + } + + const root = resizeNodeSplit(workspace.root, splitId, ratio); + if (root === workspace.root) { + return workspace; + } + + updated = true; + return { ...workspace, root }; + }); + + return updated ? { ...snapshot, workspaces } : snapshot; +} + +export function mergeLocalSplitRatios( + snapshot: WorkspaceListSnapshot, + localSnapshot: WorkspaceListSnapshot | null, +): WorkspaceListSnapshot { + if (!localSnapshot) { + return snapshot; + } + + const ratiosByWorkspace = new Map>(); + for (const workspace of localSnapshot.workspaces) { + const ratios = new Map(); + collectSplitRatios(workspace.root, ratios); + ratiosByWorkspace.set(workspace.id, ratios); + } + + let updated = false; + const workspaces = snapshot.workspaces.map((workspace) => { + const ratios = ratiosByWorkspace.get(workspace.id); + if (!ratios) { + return workspace; + } + + const root = applySplitRatios(workspace.root, ratios); + if (root === workspace.root) { + return workspace; + } + + updated = true; + return { ...workspace, root }; + }); + + return updated ? { ...snapshot, workspaces } : snapshot; +} + +function resizeNodeSplit( + node: PaneNodeSnapshot, + splitId: SplitPaneId, + ratio: number, +): PaneNodeSnapshot { + if (node.type === "leaf") { + return node; + } + + if (node.id === splitId) { + return node.ratio === ratio ? node : { ...node, ratio }; + } + + const first = resizeNodeSplit(node.first, splitId, ratio); + if (first !== node.first) { + return { ...node, first }; + } + + const second = resizeNodeSplit(node.second, splitId, ratio); + if (second !== node.second) { + return { ...node, second }; + } + + return node; +} + +function isValidSplitRatio(ratio: number): boolean { + return Number.isFinite(ratio) && ratio > 0 && ratio < 1; +} + +function collectSplitRatios(node: PaneNodeSnapshot, ratios: Map): void { + if (node.type === "leaf") { + return; + } + + ratios.set(node.id, node.ratio); + collectSplitRatios(node.first, ratios); + collectSplitRatios(node.second, ratios); +} + +function applySplitRatios( + node: PaneNodeSnapshot, + ratios: Map, +): PaneNodeSnapshot { + if (node.type === "leaf") { + return node; + } + + const ratio = ratios.get(node.id); + const first = applySplitRatios(node.first, ratios); + const second = applySplitRatios(node.second, ratios); + const nextRatio = ratio ?? node.ratio; + + if (first === node.first && second === node.second && nextRatio === node.ratio) { + return node; + } + + return { ...node, first, second, ratio: nextRatio }; +} diff --git a/desktop/src/renderer/stores/index.ts b/desktop/src/renderer/stores/index.ts new file mode 100644 index 00000000..1d04dfac --- /dev/null +++ b/desktop/src/renderer/stores/index.ts @@ -0,0 +1 @@ +export { useWorkspaceStore } from "./workspace-store"; diff --git a/desktop/src/renderer/stores/workspace-store.ts b/desktop/src/renderer/stores/workspace-store.ts new file mode 100644 index 00000000..ecbcc7dc --- /dev/null +++ b/desktop/src/renderer/stores/workspace-store.ts @@ -0,0 +1,252 @@ +import { create } from "zustand"; + +import { errorMessage } from "@/renderer/lib/errors"; +import { + activeWorkspaceFrom, + closeWorkspaceLocally, + mergeLocalSplitRatios, + resizeSplitLocally, +} from "@/renderer/lib/workspace-snapshot"; +import { + activatePane as activatePaneIpc, + activateTab as activateTabIpc, + activateWorkspace, + closeTab as closeTabIpc, + closeWorkspace as closeWorkspaceIpc, + listWorkspaces, + openTab as openTabIpc, + openWorkspace, + resizeSplit as resizeSplitIpc, + setTabKind as setTabKindIpc, + selectWorkspaceDirectory, + splitTab as splitTabIpc, + type WorkspaceListSnapshot, +} from "@/renderer/ipc"; +import type { + PaneId, + SplitAxis, + SplitPaneId, + TabId, + TabKind, + WorkspaceId, +} from "@/shared/ipc"; + +type WorkspaceRequest = () => Promise; + +type WorkspaceStore = { + error: string | null; + isAddingWorkspace: boolean; + isLoadingWorkspaces: boolean; + loadRequestId: number; + snapshot: WorkspaceListSnapshot | null; + switchRequestId: number; + activatePane(paneId: PaneId): void; + activateTab(paneId: PaneId, tabId: TabId): void; + addWorkspace(): Promise; + closeTab(paneId: PaneId, tabId: TabId): void; + closeWorkspace(workspaceId: WorkspaceId): Promise; + initializeWorkspaces(): Promise; + openTab(paneId: PaneId): void; + resizeSplit(splitId: SplitPaneId, ratio: number): void; + setTabKind(paneId: PaneId, tabId: TabId, kind: TabKind): void; + splitTab( + paneId: PaneId, + tabId: TabId, + targetPaneId: PaneId, + axis: SplitAxis, + newPaneFirst: boolean, + ): void; + switchWorkspace(workspaceId: WorkspaceId): Promise; +}; + +export const useWorkspaceStore = create((set, get) => { + function updateFromServer(request: WorkspaceRequest): void { + set({ error: null }); + + void request() + .then((nextSnapshot) => { + set((state) => ({ + snapshot: mergeLocalSplitRatios(nextSnapshot, state.snapshot), + })); + }) + .catch((caughtError: unknown) => { + set({ error: errorMessage(caughtError) }); + }); + } + + return { + error: null, + isAddingWorkspace: false, + isLoadingWorkspaces: true, + loadRequestId: 0, + snapshot: null, + switchRequestId: 0, + activatePane(paneId) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace || paneId === activeWorkspace.activePaneId) { + return; + } + + updateFromServer(() => activatePaneIpc({ workspaceId: activeWorkspace.id, paneId })); + }, + activateTab(paneId, tabId) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => activateTabIpc({ workspaceId: activeWorkspace.id, paneId, tabId })); + }, + async addWorkspace() { + set({ error: null, isAddingWorkspace: true }); + + try { + const directory = await selectWorkspaceDirectory(); + + if (!directory) { + return; + } + + set({ snapshot: await openWorkspace(directory) }); + } catch (caughtError) { + set({ error: errorMessage(caughtError) }); + } finally { + set({ isAddingWorkspace: false }); + } + }, + closeTab(paneId, tabId) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => closeTabIpc({ workspaceId: activeWorkspace.id, paneId, tabId })); + }, + async closeWorkspace(workspaceId) { + const { snapshot, switchRequestId } = get(); + const requestId = switchRequestId + 1; + const previousSnapshot = snapshot; + + set({ + error: null, + snapshot: closeWorkspaceLocally(snapshot, workspaceId), + switchRequestId: requestId, + }); + + try { + const nextSnapshot = await closeWorkspaceIpc(workspaceId); + + if (get().switchRequestId === requestId) { + set({ snapshot: nextSnapshot }); + } + } catch (caughtError) { + if (get().switchRequestId === requestId) { + set({ error: errorMessage(caughtError), snapshot: previousSnapshot }); + } + } + }, + async initializeWorkspaces() { + const requestId = get().loadRequestId + 1; + + set({ isLoadingWorkspaces: true, loadRequestId: requestId }); + + try { + const snapshot = await listWorkspaces(); + + if (get().loadRequestId === requestId) { + set({ error: null, snapshot }); + } + } catch (caughtError) { + if (get().loadRequestId === requestId) { + set({ error: errorMessage(caughtError) }); + } + } finally { + if (get().loadRequestId === requestId) { + set({ isLoadingWorkspaces: false }); + } + } + }, + openTab(paneId) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => + openTabIpc({ workspaceId: activeWorkspace.id, paneId, kind: "blank" }), + ); + }, + resizeSplit(splitId, ratio) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + set((state) => ({ + error: null, + snapshot: resizeSplitLocally(state.snapshot, activeWorkspace.id, splitId, ratio), + })); + void resizeSplitIpc({ workspaceId: activeWorkspace.id, splitId, ratio }).catch( + (caughtError: unknown) => { + set({ error: errorMessage(caughtError) }); + }, + ); + }, + setTabKind(paneId, tabId, kind) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => + setTabKindIpc({ workspaceId: activeWorkspace.id, paneId, tabId, kind }), + ); + }, + splitTab(paneId, tabId, targetPaneId, axis, newPaneFirst) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => + splitTabIpc({ + workspaceId: activeWorkspace.id, + paneId, + targetPaneId, + tabId, + axis, + newPaneFirst, + }), + ); + }, + async switchWorkspace(workspaceId) { + const { isAddingWorkspace, snapshot, switchRequestId } = get(); + if (workspaceId === snapshot?.activeWorkspaceId || isAddingWorkspace) { + return; + } + + const requestId = switchRequestId + 1; + const previousSnapshot = snapshot; + + set({ + error: null, + snapshot: snapshot ? { ...snapshot, activeWorkspaceId: workspaceId } : snapshot, + switchRequestId: requestId, + }); + + try { + const nextSnapshot = await activateWorkspace(workspaceId); + + if (get().switchRequestId === requestId) { + set((state) => ({ + snapshot: mergeLocalSplitRatios(nextSnapshot, state.snapshot), + })); + } + } catch (caughtError) { + if (get().switchRequestId === requestId) { + set({ error: errorMessage(caughtError), snapshot: previousSnapshot }); + } + } + }, + }; +}); From cd2edbf75633f2107c098141e087f439621ef100 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 02:02:42 -0300 Subject: [PATCH 28/98] feat: add file tree tab --- core/src/file_tree.rs | 341 ++++++++++++++++++ core/src/lib.rs | 1 + core/src/persistence/mod.rs | 141 +++++++- core/src/state.rs | 249 ++++++++++++- desktop/bun.lock | 9 + desktop/package.json | 1 + desktop/src/main/main.ts | 24 +- .../components/internal/workspace-view.tsx | 25 +- .../components/tabs/error-boundary.tsx | 36 ++ .../renderer/components/tabs/file-tree.tsx | 275 ++++++++++++++ .../src/renderer/components/tabs/registry.tsx | 13 +- desktop/src/renderer/ipc/file-tree.ts | 25 ++ desktop/src/renderer/ipc/index.ts | 1 + .../src/renderer/stores/workspace-store.ts | 65 +++- desktop/src/shared/ipc/envelope.ts | 2 +- desktop/src/shared/ipc/file-tree.ts | 18 + desktop/src/shared/ipc/index.ts | 1 + server/src/ipc/messages/envelope.rs | 1 + server/src/ipc/messages/file_tree.rs | 38 ++ server/src/ipc/messages/mod.rs | 1 + server/src/ipc/router/file_tree.rs | 64 ++++ server/src/ipc/router/mod.rs | 2 + server/src/ipc/transport/connection.rs | 1 + 23 files changed, 1314 insertions(+), 20 deletions(-) create mode 100644 core/src/file_tree.rs create mode 100644 desktop/src/renderer/components/tabs/error-boundary.tsx create mode 100644 desktop/src/renderer/components/tabs/file-tree.tsx create mode 100644 desktop/src/renderer/ipc/file-tree.ts create mode 100644 desktop/src/shared/ipc/file-tree.ts create mode 100644 server/src/ipc/messages/file_tree.rs create mode 100644 server/src/ipc/router/file_tree.rs diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs new file mode 100644 index 00000000..7b41ef4d --- /dev/null +++ b/core/src/file_tree.rs @@ -0,0 +1,341 @@ +use std::cmp::Ordering; +use std::collections::HashSet; +use std::error::Error as StdError; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::tree::{TabId, WorkspaceId}; + +pub type Result = std::result::Result; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileTree { + root: PathBuf, + paths: Vec, + expanded_paths: Vec, +} + +impl FileTree { + pub fn scan(root: impl Into) -> Result { + Self::scan_with_expanded_paths(root, &[]) + } + + pub fn scan_with_expanded_paths( + root: impl Into, + expanded_paths: &[String], + ) -> Result { + let root = root.into(); + ensure_directory(&root)?; + + let mut paths = Vec::new(); + let mut directory_paths = HashSet::new(); + collect_paths(&root, "", &mut paths, &mut directory_paths)?; + let expanded_paths = normalize_expanded_paths(expanded_paths) + .into_iter() + .filter(|path| directory_paths.contains(path)) + .collect(); + + Ok(Self { + root, + paths, + expanded_paths, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn paths(&self) -> &[String] { + &self.paths + } + + pub fn expanded_paths(&self) -> &[String] { + &self.expanded_paths + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileTreeViewState { + workspace_id: WorkspaceId, + tab_id: TabId, + expanded_paths: Vec, +} + +impl FileTreeViewState { + pub fn new(workspace_id: WorkspaceId, tab_id: TabId, expanded_paths: Vec) -> Self { + Self { + workspace_id, + tab_id, + expanded_paths: normalize_expanded_paths(&expanded_paths), + } + } + + pub fn workspace_id(&self) -> WorkspaceId { + self.workspace_id + } + + pub fn tab_id(&self) -> TabId { + self.tab_id + } + + pub fn expanded_paths(&self) -> &[String] { + &self.expanded_paths + } +} + +#[derive(Debug)] +pub enum FileTreeError { + Io { path: PathBuf, error: io::Error }, + RootNotDirectory(PathBuf), + TabNotFound, + WorkspaceNotFound, +} + +impl fmt::Display for FileTreeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, error } => { + write!(formatter, "could not read {}: {error}", path.display()) + } + Self::RootNotDirectory(path) => { + write!(formatter, "{} is not a directory", path.display()) + } + Self::TabNotFound => formatter.write_str("file tree tab does not exist"), + Self::WorkspaceNotFound => formatter.write_str("workspace does not exist"), + } + } +} + +impl StdError for FileTreeError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::Io { error, .. } => Some(error), + Self::RootNotDirectory(_) | Self::TabNotFound | Self::WorkspaceNotFound => None, + } + } +} + +fn ensure_directory(root: &Path) -> Result<()> { + let metadata = fs::metadata(root).map_err(|error| io_error(root, error))?; + + if metadata.is_dir() { + Ok(()) + } else { + Err(FileTreeError::RootNotDirectory(root.to_path_buf())) + } +} + +fn collect_paths( + directory: &Path, + relative_directory: &str, + paths: &mut Vec, + directory_paths: &mut HashSet, +) -> Result<()> { + for entry in sorted_entries(directory)? { + let relative_path = relative_path(relative_directory, &entry.name); + + if entry.is_directory { + paths.push(format!("{relative_path}/")); + directory_paths.insert(format!("{relative_path}/")); + collect_paths(&entry.path, &relative_path, paths, directory_paths)?; + } else { + paths.push(relative_path); + } + } + + Ok(()) +} + +fn sorted_entries(directory: &Path) -> Result> { + let entries = fs::read_dir(directory).map_err(|error| io_error(directory, error))?; + let mut file_tree_entries = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|error| io_error(directory, error))?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| io_error(&path, error))?; + + file_tree_entries.push(FileTreeEntry { + name: entry.file_name().to_string_lossy().into_owned(), + path, + is_directory: file_type.is_dir(), + }); + } + + file_tree_entries.sort_by(compare_entries); + + Ok(file_tree_entries) +} + +fn compare_entries(left: &FileTreeEntry, right: &FileTreeEntry) -> Ordering { + match (left.is_directory, right.is_directory) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => left + .name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.name.cmp(&right.name)), + } +} + +fn relative_path(relative_directory: &str, name: &str) -> String { + if relative_directory.is_empty() { + name.to_owned() + } else { + format!("{relative_directory}/{name}") + } +} + +fn io_error(path: impl Into, error: io::Error) -> FileTreeError { + FileTreeError::Io { + path: path.into(), + error, + } +} + +fn normalize_expanded_paths(paths: &[String]) -> Vec { + let mut normalized_paths = paths + .iter() + .filter_map(|path| normalize_expanded_path(path)) + .collect::>(); + + normalized_paths.sort(); + normalized_paths.dedup(); + + normalized_paths +} + +fn normalize_expanded_path(path: &str) -> Option { + if path.is_empty() || path.starts_with('/') || path.contains('\\') || path.contains('\0') { + return None; + } + + let path = path.strip_suffix('/').unwrap_or(path); + if path.is_empty() { + return None; + } + + if path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return None; + } + + Some(format!("{path}/")) +} + +#[derive(Debug)] +struct FileTreeEntry { + name: String, + path: PathBuf, + is_directory: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn scans_directories_and_files_as_relative_tree_paths() { + let root = test_root("scan"); + fs::create_dir_all(root.join("src/components")).expect("test directory should be created"); + fs::create_dir(root.join("empty")).expect("empty test directory should be created"); + fs::write(root.join("README.md"), b"readme").expect("readme should be written"); + fs::write(root.join("src/main.rs"), b"fn main() {}").expect("main should be written"); + fs::write(root.join("src/components/Button.tsx"), b"export {}") + .expect("component should be written"); + + let tree = FileTree::scan(&root).expect("file tree should scan"); + + assert_eq!(tree.root(), root.as_path()); + assert_eq!( + tree.paths(), + &[ + "empty/", + "src/", + "src/components/", + "src/components/Button.tsx", + "src/main.rs", + "README.md", + ] + ); + assert!(tree.expanded_paths().is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn scan_returns_normalized_existing_expanded_directories() { + let root = test_root("expanded"); + fs::create_dir_all(root.join("src/components")).expect("test directory should be created"); + fs::create_dir(root.join("target")).expect("target test directory should be created"); + fs::write(root.join("src/main.rs"), b"fn main() {}").expect("main should be written"); + + let expanded_paths = vec![ + "src".to_owned(), + "src/components/".to_owned(), + "missing/".to_owned(), + "../outside/".to_owned(), + ]; + let tree = FileTree::scan_with_expanded_paths(&root, &expanded_paths) + .expect("file tree should scan"); + + assert_eq!(tree.expanded_paths(), &["src/", "src/components/"]); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn view_state_normalizes_expanded_paths() { + let state = FileTreeViewState::new( + WorkspaceId::new(1), + TabId::new(2), + vec![ + "src".to_owned(), + "src/".to_owned(), + "/absolute/".to_owned(), + "src/../target/".to_owned(), + "target/".to_owned(), + ], + ); + + assert_eq!(state.workspace_id(), WorkspaceId::new(1)); + assert_eq!(state.tab_id(), TabId::new(2)); + assert_eq!(state.expanded_paths(), &["src/", "target/"]); + } + + #[test] + fn rejects_non_directory_roots() { + let root = test_root("file-root"); + fs::create_dir_all(&root).expect("test directory should be created"); + let file = root.join("file.txt"); + fs::write(&file, b"not a directory").expect("test file should be written"); + + let error = FileTree::scan(&file).expect_err("file roots should be rejected"); + + assert!(matches!(error, FileTreeError::RootNotDirectory(path) if path == file)); + + let _ = fs::remove_dir_all(root); + } + + fn test_root(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "kosmos-core-file-tree-{}-{name}-{nanos}", + std::process::id() + )); + + fs::create_dir_all(&root).expect("test root should be created"); + + root + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f5aaff37..ec1fd6a2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,6 @@ mod state; +pub mod file_tree; pub mod persistence; pub use state::State; pub mod tree; diff --git a/core/src/persistence/mod.rs b/core/src/persistence/mod.rs index 3d1cdabb..9fd1a010 100644 --- a/core/src/persistence/mod.rs +++ b/core/src/persistence/mod.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use crate::State; +use crate::file_tree::FileTreeViewState; use crate::tree::{ Pane, PaneId, PaneNode, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, }; @@ -157,6 +158,16 @@ fn migrate(connection: &Connection) -> Result<()> { FOREIGN KEY (workspace_id, pane_id) REFERENCES panes(workspace_id, id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS file_tree_expanded_paths ( + workspace_id INTEGER NOT NULL, + tab_id INTEGER NOT NULL, + position INTEGER NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (workspace_id, tab_id, path), + UNIQUE (workspace_id, tab_id, position), + FOREIGN KEY (workspace_id, tab_id) REFERENCES tabs(workspace_id, id) ON DELETE CASCADE + ); + UPDATE tabs SET kind = 'blank' WHERE kind NOT IN ('blank', 'file_tree', 'editor', 'git', 'search', 'terminal', 'settings'); @@ -168,6 +179,7 @@ fn migrate(connection: &Connection) -> Result<()> { fn clear_state(transaction: &Transaction<'_>) -> Result<()> { transaction.execute("DELETE FROM metadata", [])?; + transaction.execute("DELETE FROM file_tree_expanded_paths", [])?; transaction.execute("DELETE FROM tabs", [])?; transaction.execute("DELETE FROM panes", [])?; transaction.execute("DELETE FROM pane_nodes", [])?; @@ -200,6 +212,8 @@ fn save_state(transaction: &Transaction<'_>, state: &State) -> Result<()> { save_node(transaction, workspace.id(), "", workspace.root())?; } + save_file_tree_view_states(transaction, state)?; + Ok(()) } @@ -310,6 +324,27 @@ fn save_pane(transaction: &Transaction<'_>, workspace_id: WorkspaceId, pane: &Pa Ok(()) } +fn save_file_tree_view_states(transaction: &Transaction<'_>, state: &State) -> Result<()> { + for view_state in state.file_tree_view_states() { + for (position, path) in view_state.expanded_paths().iter().enumerate() { + transaction.execute( + " + INSERT INTO file_tree_expanded_paths (workspace_id, tab_id, position, path) + VALUES (?1, ?2, ?3, ?4) + ", + params![ + to_i64(view_state.workspace_id().value(), "workspace id")?, + to_i64(view_state.tab_id().value(), "tab id")?, + usize_to_i64(position, "file tree expanded path position")?, + path, + ], + )?; + } + } + + Ok(()) +} + fn load_state(connection: &Connection) -> Result { let active_workspace_id = load_active_workspace_id(connection)?; let workspace_rows = load_workspace_rows(connection)?; @@ -329,8 +364,14 @@ fn load_state(connection: &Connection) -> Result { workspaces.push(workspace); } - State::from_workspaces(workspaces, active_workspace_id) - .ok_or_else(|| invalid_state("persisted workspaces are not internally consistent")) + let file_tree_view_states = load_file_tree_view_states(connection)?; + + State::from_workspaces_with_file_tree_view_states( + workspaces, + active_workspace_id, + file_tree_view_states, + ) + .ok_or_else(|| invalid_state("persisted workspaces are not internally consistent")) } fn load_active_workspace_id(connection: &Connection) -> Result> { @@ -541,6 +582,61 @@ fn load_tabs( Ok(tabs) } +fn load_file_tree_view_states(connection: &Connection) -> Result> { + let mut statement = connection.prepare( + " + SELECT workspace_id, tab_id, path + FROM file_tree_expanded_paths + ORDER BY workspace_id, tab_id, position + ", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut view_states = Vec::new(); + let mut current_key: Option<(WorkspaceId, TabId)> = None; + let mut current_paths = Vec::new(); + + for row in rows { + let (workspace_id, tab_id, path) = row?; + let key = ( + WorkspaceId::new(to_u64(workspace_id, "workspace id")?), + TabId::new(to_u64(tab_id, "tab id")?), + ); + + if current_key.is_some_and(|current_key| current_key != key) { + push_file_tree_view_state(&mut view_states, current_key, &mut current_paths); + } + + current_key = Some(key); + current_paths.push(path); + } + + push_file_tree_view_state(&mut view_states, current_key, &mut current_paths); + + Ok(view_states) +} + +fn push_file_tree_view_state( + view_states: &mut Vec, + key: Option<(WorkspaceId, TabId)>, + paths: &mut Vec, +) { + let Some((workspace_id, tab_id)) = key else { + return; + }; + let paths = std::mem::take(paths); + let view_state = FileTreeViewState::new(workspace_id, tab_id, paths); + + if !view_state.expanded_paths().is_empty() { + view_states.push(view_state); + } +} + fn child_path(path: &str, index: u8) -> String { if path.is_empty() { index.to_string() @@ -704,6 +800,35 @@ mod tests { let _ = fs::remove_file(path); } + #[test] + fn saves_and_loads_file_tree_view_state() { + let path = test_db_path("file-tree-view-state"); + let workspace_path = test_workspace_path("file-tree-view-state-workspace"); + fs::create_dir_all(workspace_path.join("src/components")) + .expect("workspace directories should be created"); + let store = StateStore::open(&path).expect("store should open"); + let mut state = State::new(); + let workspace_id = state.open_workspace(&workspace_path); + assert!(state.set_tab_kind(None, PaneId::new(1), TabId::new(1), TabKind::FileTree)); + assert!(state.set_file_tree_expanded_paths( + Some(workspace_id), + TabId::new(1), + vec!["src".to_owned(), "src/components".to_owned()], + )); + + store.save(&state).expect("state should save"); + + let loaded = store.load().expect("state should load"); + let file_tree = loaded + .file_tree(Some(workspace_id), Some(TabId::new(1))) + .expect("file tree should load"); + + assert_eq!(file_tree.expanded_paths(), &["src/", "src/components/"]); + + let _ = fs::remove_file(path); + let _ = fs::remove_dir_all(workspace_path); + } + fn test_db_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -715,4 +840,16 @@ mod tests { std::process::id() )) } + + fn test_workspace_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + + std::env::temp_dir().join(format!( + "kosmos-core-persistence-workspace-{}-{name}-{nanos}", + std::process::id() + )) + } } diff --git a/core/src/state.rs b/core/src/state.rs index 7acfe10e..a583ed3f 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,13 +1,15 @@ use std::path::PathBuf; +use crate::file_tree::{FileTree, FileTreeError, FileTreeViewState}; use crate::tree::{ - Pane, PaneId, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, + Pane, PaneId, PaneNode, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, }; #[derive(Debug)] pub struct State { workspaces: WorkspaceList, + file_tree_view_states: Vec, next_workspace_id: u64, next_pane_id: u64, next_split_id: u64, @@ -22,6 +24,18 @@ impl State { pub fn from_workspaces( workspaces: Vec, active_workspace_id: Option, + ) -> Option { + Self::from_workspaces_with_file_tree_view_states( + workspaces, + active_workspace_id, + Vec::new(), + ) + } + + pub fn from_workspaces_with_file_tree_view_states( + workspaces: Vec, + active_workspace_id: Option, + file_tree_view_states: Vec, ) -> Option { let mut workspace_list = WorkspaceList::new(); @@ -38,13 +52,65 @@ impl State { _ => return None, } - Self::from_workspace_list(workspace_list) + Self::from_workspace_list(workspace_list, file_tree_view_states) } pub fn workspaces(&self) -> &WorkspaceList { &self.workspaces } + pub fn file_tree_view_states(&self) -> &[FileTreeViewState] { + &self.file_tree_view_states + } + + pub fn file_tree( + &self, + workspace_id: Option, + tab_id: Option, + ) -> Result { + let workspace_id = self + .resolve_workspace_id(workspace_id) + .ok_or(FileTreeError::WorkspaceNotFound)?; + let workspace = self + .workspaces + .workspace(workspace_id) + .ok_or(FileTreeError::WorkspaceNotFound)?; + let expanded_paths = match tab_id { + Some(tab_id) if self.is_file_tree_tab(workspace_id, tab_id) => self + .file_tree_view_state(workspace_id, tab_id) + .map(FileTreeViewState::expanded_paths) + .unwrap_or(&[]), + Some(_) => return Err(FileTreeError::TabNotFound), + None => &[], + }; + + FileTree::scan_with_expanded_paths(workspace.directory(), expanded_paths) + } + + pub fn set_file_tree_expanded_paths( + &mut self, + workspace_id: Option, + tab_id: TabId, + expanded_paths: Vec, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + + if !self.is_file_tree_tab(workspace_id, tab_id) { + return false; + } + + let view_state = FileTreeViewState::new(workspace_id, tab_id, expanded_paths); + self.remove_file_tree_view_state(workspace_id, tab_id); + + if !view_state.expanded_paths().is_empty() { + self.file_tree_view_states.push(view_state); + } + + true + } + pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { let directory = directory.into(); @@ -73,10 +139,16 @@ impl State { } pub fn close_workspace(&mut self, workspace_id: Option) -> Option { - match workspace_id { + let closed_workspace = match workspace_id { Some(workspace_id) => self.workspaces.close_workspace(workspace_id), None => self.workspaces.close_active_workspace(), + }; + + if let Some(workspace) = &closed_workspace { + self.remove_workspace_file_tree_view_states(workspace.id()); } + + closed_workspace } pub fn split_pane( @@ -184,6 +256,7 @@ impl State { tab_id: TabId, kind: TabKind, ) -> bool { + let keep_file_tree_state = kind == TabKind::FileTree; let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { return false; }; @@ -191,7 +264,13 @@ impl State { return false; }; - workspace.set_tab_kind(pane_id, tab_id, kind) + let updated = workspace.set_tab_kind(pane_id, tab_id, kind); + + if updated && !keep_file_tree_state { + self.remove_file_tree_view_state(workspace_id, tab_id); + } + + updated } pub fn split_tab( @@ -259,7 +338,13 @@ impl State { let fallback_pane = self.blank_pane(); let workspace = self.workspace_mut(workspace_id)?; - workspace.close_tab(pane_id, tab_id, fallback_pane) + let removed_tab = workspace.close_tab(pane_id, tab_id, fallback_pane); + + if removed_tab.is_some() { + self.remove_file_tree_view_state(workspace_id, tab_id); + } + + removed_tab } pub fn reorder_tab( @@ -338,11 +423,52 @@ impl State { Tab::new(self.next_tab_id(), title, kind) } - fn from_workspace_list(workspaces: WorkspaceList) -> Option { + fn file_tree_view_state( + &self, + workspace_id: WorkspaceId, + tab_id: TabId, + ) -> Option<&FileTreeViewState> { + self.file_tree_view_states + .iter() + .find(|state| state.workspace_id() == workspace_id && state.tab_id() == tab_id) + } + + fn remove_file_tree_view_state(&mut self, workspace_id: WorkspaceId, tab_id: TabId) { + self.file_tree_view_states + .retain(|state| state.workspace_id() != workspace_id || state.tab_id() != tab_id); + } + + fn remove_workspace_file_tree_view_states(&mut self, workspace_id: WorkspaceId) { + self.file_tree_view_states + .retain(|state| state.workspace_id() != workspace_id); + } + + fn is_file_tree_tab(&self, workspace_id: WorkspaceId, tab_id: TabId) -> bool { + self.tab_kind(workspace_id, tab_id) == Some(&TabKind::FileTree) + } + + fn tab_kind(&self, workspace_id: WorkspaceId, tab_id: TabId) -> Option<&TabKind> { + let workspace = self.workspaces.workspace(workspace_id)?; + + tab_kind_in_node(workspace.root(), tab_id) + } + + fn from_workspace_list( + workspaces: WorkspaceList, + file_tree_view_states: Vec, + ) -> Option { let next_ids = NextIds::from_workspaces(&workspaces)?; + if file_tree_view_states.iter().any(|state| { + tab_kind_in_workspace_list(&workspaces, state.workspace_id(), state.tab_id()) + != Some(&TabKind::FileTree) + }) { + return None; + } + Some(Self { workspaces, + file_tree_view_states, next_workspace_id: next_ids.workspace_id, next_pane_id: next_ids.pane_id, next_split_id: next_ids.split_id, @@ -414,10 +540,33 @@ fn next_id_after(id: u64) -> Option { id.checked_add(1) } +fn tab_kind_in_workspace_list( + workspaces: &WorkspaceList, + workspace_id: WorkspaceId, + tab_id: TabId, +) -> Option<&TabKind> { + let workspace = workspaces.workspace(workspace_id)?; + + tab_kind_in_node(workspace.root(), tab_id) +} + +fn tab_kind_in_node(node: &PaneNode, tab_id: TabId) -> Option<&TabKind> { + match node { + PaneNode::Leaf(pane) => pane + .tabs() + .iter() + .find(|tab| tab.id() == tab_id) + .map(Tab::kind), + PaneNode::Split(split) => tab_kind_in_node(split.first(), tab_id) + .or_else(|| tab_kind_in_node(split.second(), tab_id)), + } +} + impl Default for State { fn default() -> Self { Self { workspaces: WorkspaceList::new(), + file_tree_view_states: Vec::new(), next_workspace_id: 1, next_pane_id: 1, next_split_id: 1, @@ -632,4 +781,92 @@ mod tests { assert_eq!(split.ratio(), 0.7); } + + #[test] + fn file_tree_requires_a_workspace() { + let state = State::new(); + + let error = state + .file_tree(None, None) + .expect_err("missing workspace should fail"); + + assert!(matches!(error, FileTreeError::WorkspaceNotFound)); + } + + #[test] + fn file_tree_expanded_paths_are_stored_for_file_tree_tabs() { + let root = test_directory("file-tree-state"); + std::fs::create_dir(root.join("src")).expect("test directory should be created"); + let mut state = State::new(); + let workspace_id = state.open_workspace(&root); + assert!(state.set_tab_kind(None, PaneId::new(1), TabId::new(1), TabKind::FileTree)); + + assert!(state.set_file_tree_expanded_paths( + Some(workspace_id), + TabId::new(1), + vec!["src".to_owned(), "missing".to_owned()], + )); + + let file_tree = state + .file_tree(Some(workspace_id), Some(TabId::new(1))) + .expect("file tree should load"); + + assert_eq!(file_tree.expanded_paths(), &["src/"]); + assert_eq!(state.file_tree_view_states().len(), 1); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn file_tree_expanded_paths_require_a_file_tree_tab() { + let mut state = State::new(); + let workspace_id = state.open_workspace("/workspaces/main"); + + assert!(!state.set_file_tree_expanded_paths( + Some(workspace_id), + TabId::new(1), + vec!["src".to_owned()], + )); + + let error = state + .file_tree(Some(workspace_id), Some(TabId::new(1))) + .expect_err("blank tabs should not expose file tree state"); + + assert!(matches!(error, FileTreeError::TabNotFound)); + } + + #[test] + fn closing_tab_removes_file_tree_view_state() { + let mut state = State::new(); + let workspace_id = state.open_workspace("/workspaces/main"); + assert!(state.set_tab_kind(None, PaneId::new(1), TabId::new(1), TabKind::FileTree)); + assert!(state.set_file_tree_expanded_paths( + Some(workspace_id), + TabId::new(1), + vec!["src".to_owned()], + )); + + assert!( + state + .close_tab(Some(workspace_id), PaneId::new(1), TabId::new(1)) + .is_some() + ); + + assert!(state.file_tree_view_states().is_empty()); + } + + fn test_directory(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "kosmos-core-state-{}-{name}-{nanos}", + std::process::id() + )); + + std::fs::create_dir_all(&root).expect("test root should be created"); + + root + } } diff --git a/desktop/bun.lock b/desktop/bun.lock index 90acf712..d91d4205 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/geist": "^5.2.9", + "@pierre/trees": "^1.0.0-beta.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", @@ -141,6 +142,10 @@ "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], + + "@pierre/trees": ["@pierre/trees@1.0.0-beta.5", "", { "dependencies": { "@pierre/theming": "0.0.2", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], @@ -615,6 +620,10 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], diff --git a/desktop/package.json b/desktop/package.json index a4aaf516..cc36ce37 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -28,6 +28,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/geist": "^5.2.9", + "@pierre/trees": "^1.0.0-beta.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 2bdcb833..11567d2d 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -5,7 +5,7 @@ import path from "node:path"; import type { KosmosIpcDomain, KosmosIpcRequest } from "../shared/ipc"; import { KosmosServerClient } from "./server-client"; -const validDomains = new Set(["workspace", "pane", "tab"]); +const validDomains = new Set(["workspace", "pane", "tab", "fileTree"]); const serverClient = new KosmosServerClient(); function registerIpcHandlers(): void { @@ -67,9 +67,31 @@ function createMainWindow(): void { }, }); + registerWindowShortcuts(window); + void window.loadFile(path.join(runtimeDirectory, "renderer", "index.html")); } +function registerWindowShortcuts(window: BrowserWindow): void { + window.webContents.on("before-input-event", (event, input) => { + const key = input.key.toLowerCase(); + const togglesDevTools = + input.key === "F12" || ((input.control || input.meta) && input.shift && key === "i"); + + if (!togglesDevTools) { + return; + } + + event.preventDefault(); + + if (window.webContents.isDevToolsOpened()) { + window.webContents.closeDevTools(); + } else { + window.webContents.openDevTools({ mode: "detach" }); + } + }); +} + function getRuntimeDirectory(): string { const packageJsonPath = path.join(app.getAppPath(), "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index ced53635..6b0f7e29 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -2,6 +2,7 @@ import { useState, type DragEvent } from "react"; import { Plus, X } from "lucide-react"; import { Button } from "@/renderer/components/ui/button"; +import { TabErrorBoundary } from "@/renderer/components/tabs/error-boundary"; import { ContextMenu, ContextMenuContent, @@ -90,7 +91,7 @@ function PaneNodeView({ const resizeSplit = useWorkspaceStore((state) => state.resizeSplit); if (node.type === "leaf") { - return ; + return ; } const firstPanelId = `split-${node.id}-first`; @@ -148,9 +149,11 @@ function PaneNodeView({ function PaneLeaf({ pane, + workspaceId, isRoot, }: { pane: PaneSnapshot; + workspaceId: WorkspaceId; isRoot: boolean; }) { const [dropEdge, setDropEdge] = useState(null); @@ -241,6 +244,7 @@ function PaneLeaf({ activatePane(pane.id)} /> @@ -311,20 +315,27 @@ function TabTrigger({ function TabBody({ paneId, tab, + workspaceId, onActivatePane, }: { paneId: PaneId; tab: TabSnapshot; + workspaceId: WorkspaceId; onActivatePane(): void; }) { const setTabKind = useWorkspaceStore((state) => state.setTabKind); - return renderTabContent({ - paneId, - tab, - onActivatePane, - onSetTabKind: (kind) => setTabKind(paneId, tab.id, kind), - }); + return ( + + {renderTabContent({ + paneId, + tab, + workspaceId, + onActivatePane, + onSetTabKind: (kind) => setTabKind(paneId, tab.id, kind), + })} + + ); } function DropIndicator({ edge }: { edge: DropEdge | null }) { diff --git a/desktop/src/renderer/components/tabs/error-boundary.tsx b/desktop/src/renderer/components/tabs/error-boundary.tsx new file mode 100644 index 00000000..6c4b24ce --- /dev/null +++ b/desktop/src/renderer/components/tabs/error-boundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +type TabErrorBoundaryProps = { + children: ReactNode; +}; + +type TabErrorBoundaryState = { + error: Error | null; +}; + +export class TabErrorBoundary extends Component { + override state: TabErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): TabErrorBoundaryState { + return { error }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("Tab crashed", error, errorInfo); + } + + override render() { + if (this.state.error) { + return ( +
+
+

This tab crashed

+

{this.state.error.message}

+
+
+ ); + } + + return this.props.children; + } +} diff --git a/desktop/src/renderer/components/tabs/file-tree.tsx b/desktop/src/renderer/components/tabs/file-tree.tsx new file mode 100644 index 00000000..b593e320 --- /dev/null +++ b/desktop/src/renderer/components/tabs/file-tree.tsx @@ -0,0 +1,275 @@ +import type { + FileTree as FileTreeModel, + FileTreeDirectoryHandle, + FileTreeItemHandle, +} from "@pierre/trees"; +import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react"; +import { useEffect, useRef, useState } from "react"; + +import { getFileTree } from "@/renderer/ipc"; +import { errorMessage } from "@/renderer/lib/errors"; +import { useWorkspaceStore } from "@/renderer/stores"; +import type { FileTreeSnapshot, TabId, WorkspaceId } from "@/shared/ipc"; + +type FileTreeTabProps = { + workspaceId: WorkspaceId; + tabId: TabId; + onActivatePane(): void; +}; + +type FileTreeLoadState = + | { status: "loading"; workspaceId: WorkspaceId; tabId: TabId } + | { status: "loaded"; workspaceId: WorkspaceId; tabId: TabId; snapshot: FileTreeSnapshot } + | { status: "error"; workspaceId: WorkspaceId; tabId: TabId; message: string }; + +const EXPANSION_SAVE_DELAY_MS = 250; + +export function FileTreeTab({ workspaceId, tabId, onActivatePane }: FileTreeTabProps) { + const [loadState, setLoadState] = useState({ + status: "loading", + workspaceId, + tabId, + }); + + useEffect(() => { + let isCurrent = true; + + setLoadState({ status: "loading", workspaceId, tabId }); + + void getFileTree({ workspaceId, tabId }) + .then((snapshot) => { + if (isCurrent) { + setLoadState({ status: "loaded", workspaceId, tabId, snapshot }); + } + }) + .catch((caughtError: unknown) => { + if (isCurrent) { + setLoadState({ status: "error", workspaceId, tabId, message: errorMessage(caughtError) }); + } + }); + + return () => { + isCurrent = false; + }; + }, [workspaceId, tabId]); + + const currentLoadState: FileTreeLoadState = + loadState.workspaceId === workspaceId && loadState.tabId === tabId + ? loadState + : { status: "loading", workspaceId, tabId }; + + return ( +
+ {currentLoadState.status === "loading" ? : null} + {currentLoadState.status === "error" ? ( + + ) : null} + {currentLoadState.status === "loaded" ? ( + + ) : null} +
+ ); +} + +function LoadedFileTree({ + workspaceId, + tabId, + snapshot, +}: { + workspaceId: WorkspaceId; + tabId: TabId; + snapshot: FileTreeSnapshot; +}) { + const { model } = useFileTree({ + density: "compact", + flattenEmptyDirectories: true, + initialExpandedPaths: snapshot.expandedPaths, + initialExpansion: "closed", + paths: snapshot.paths, + }); + + if (snapshot.paths.length === 0) { + return ; + } + + return ( + <> + + + + ); +} + +function FileTreeExpansionPersistence({ + model, + snapshot, + workspaceId, + tabId, +}: { + model: FileTreeModel; + snapshot: FileTreeSnapshot; + workspaceId: WorkspaceId; + tabId: TabId; +}) { + const registerFileTreeExpansionFlusher = useWorkspaceStore( + (state) => state.registerFileTreeExpansionFlusher, + ); + const saveFileTreeExpandedPaths = useWorkspaceStore((state) => state.saveFileTreeExpandedPaths); + const persistedExpandedPathsRef = useRef(sortedUnique(snapshot.expandedPaths)); + const pendingExpandedPathsRef = useRef(null); + + useEffect(() => { + persistedExpandedPathsRef.current = sortedUnique(snapshot.expandedPaths); + pendingExpandedPathsRef.current = null; + }, [snapshot.expandedPaths]); + + useEffect(() => { + const directoryPaths = snapshot.paths.filter((path) => path.endsWith("/")); + let saveTimeout: ReturnType | null = null; + + const persistExpandedPaths = (expandedPaths: string[]) => { + return saveFileTreeExpandedPaths({ workspaceId, tabId, expandedPaths }); + }; + + const saveExpandedPaths = (): Promise | void => { + const expandedPaths = expandedDirectoryPaths(model, directoryPaths); + const targetExpandedPaths = pendingExpandedPathsRef.current ?? persistedExpandedPathsRef.current; + if (stringArraysEqual(targetExpandedPaths, expandedPaths)) { + return; + } + + pendingExpandedPathsRef.current = expandedPaths; + + if (saveTimeout !== null) { + clearTimeout(saveTimeout); + } + + saveTimeout = setTimeout(() => { + const pendingExpandedPaths = pendingExpandedPathsRef.current; + + saveTimeout = null; + pendingExpandedPathsRef.current = null; + + if ( + pendingExpandedPaths && + !stringArraysEqual(persistedExpandedPathsRef.current, pendingExpandedPaths) + ) { + persistedExpandedPathsRef.current = pendingExpandedPaths; + void persistExpandedPaths(pendingExpandedPaths); + } + }, EXPANSION_SAVE_DELAY_MS); + }; + + const flushExpandedPaths = () => { + const expandedPaths = expandedDirectoryPaths(model, directoryPaths); + + if (saveTimeout !== null) { + clearTimeout(saveTimeout); + saveTimeout = null; + } + + pendingExpandedPathsRef.current = null; + + if (stringArraysEqual(persistedExpandedPathsRef.current, expandedPaths)) { + return undefined; + } + + persistedExpandedPathsRef.current = expandedPaths; + + return persistExpandedPaths(expandedPaths); + }; + + const unsubscribe = model.subscribe(saveExpandedPaths); + const unregisterFlusher = registerFileTreeExpansionFlusher(flushExpandedPaths); + + return () => { + unsubscribe(); + unregisterFlusher(); + void flushExpandedPaths(); + }; + }, [ + model, + registerFileTreeExpansionFlusher, + saveFileTreeExpandedPaths, + snapshot.paths, + workspaceId, + tabId, + ]); + + return null; +} + +function FileTreeMessage({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +function expandedDirectoryPaths(model: FileTreeModel, directoryPaths: string[]): string[] { + return sortedUnique( + directoryPaths.filter((path) => { + const item = model.getItem(path); + if (!isDirectoryHandle(item)) { + return false; + } + + return item.isExpanded() && ancestorDirectoriesAreExpanded(model, path); + }), + ); +} + +function ancestorDirectoriesAreExpanded(model: FileTreeModel, path: string): boolean { + for (const ancestorPath of ancestorDirectoryPaths(path)) { + const ancestor = model.getItem(ancestorPath); + + if (!isDirectoryHandle(ancestor) || !ancestor.isExpanded()) { + return false; + } + } + + return true; +} + +function ancestorDirectoryPaths(path: string): string[] { + const segments = path.replace(/\/$/, "").split("/"); + const ancestors: string[] = []; + + for (let index = 1; index < segments.length; index += 1) { + ancestors.push(`${segments.slice(0, index).join("/")}/`); + } + + return ancestors; +} + +function sortedUnique(paths: string[]): string[] { + return [...new Set(paths)].sort(); +} + +function stringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +} + +function isDirectoryHandle( + item: FileTreeItemHandle | null, +): item is FileTreeDirectoryHandle { + return item?.isDirectory() === true; +} diff --git a/desktop/src/renderer/components/tabs/registry.tsx b/desktop/src/renderer/components/tabs/registry.tsx index 3518332e..00eb3d6d 100644 --- a/desktop/src/renderer/components/tabs/registry.tsx +++ b/desktop/src/renderer/components/tabs/registry.tsx @@ -10,14 +10,16 @@ import { type LucideIcon, } from "lucide-react"; -import type { PaneId, TabKind, TabSnapshot } from "@/shared/ipc"; +import type { PaneId, TabKind, TabSnapshot, WorkspaceId } from "@/shared/ipc"; import { BlankTab, type BlankTabOption } from "./blank"; +import { FileTreeTab } from "./file-tree"; import { PlaceholderTab } from "./placeholder"; type TabContentProps = { paneId: PaneId; tab: TabSnapshot; + workspaceId: WorkspaceId; onActivatePane(): void; onSetTabKind(kind: TabKind): void; }; @@ -52,7 +54,14 @@ const TAB_DEFINITIONS: Record = { /> ), }, - fileTree: placeholderTabDefinition("File Tree", FolderTree, true), + fileTree: { + icon: FolderTree, + label: "File Tree", + showInBlankPicker: true, + render: ({ tab, workspaceId, onActivatePane }) => ( + + ), + }, editor: placeholderTabDefinition("Editor", FileText, false), git: placeholderTabDefinition("Git", GitBranch, true), search: placeholderTabDefinition("Search", Search, true), diff --git a/desktop/src/renderer/ipc/file-tree.ts b/desktop/src/renderer/ipc/file-tree.ts new file mode 100644 index 00000000..6dee33a8 --- /dev/null +++ b/desktop/src/renderer/ipc/file-tree.ts @@ -0,0 +1,25 @@ +import type { + FileTreeSnapshot, + GetFileTreeParams, + SetFileTreeExpandedPathsParams, +} from "@/shared/ipc"; + +import { requestServer } from "./transport"; + +const DOMAIN = "fileTree"; + +export function getFileTree(params: GetFileTreeParams = {}): Promise { + return requestServer(DOMAIN, "get", params); +} + +export function setFileTreeExpandedPaths( + params: SetFileTreeExpandedPathsParams, +): Promise { + return requestServer(DOMAIN, "setExpandedPaths", params); +} + +export type { + FileTreeSnapshot, + GetFileTreeParams, + SetFileTreeExpandedPathsParams, +} from "@/shared/ipc"; diff --git a/desktop/src/renderer/ipc/index.ts b/desktop/src/renderer/ipc/index.ts index 2a475759..55d2a2e6 100644 --- a/desktop/src/renderer/ipc/index.ts +++ b/desktop/src/renderer/ipc/index.ts @@ -1,3 +1,4 @@ +export * from "./file-tree"; export * from "./pane"; export * from "./tab"; export * from "./transport"; diff --git a/desktop/src/renderer/stores/workspace-store.ts b/desktop/src/renderer/stores/workspace-store.ts index ecbcc7dc..ea2b4917 100644 --- a/desktop/src/renderer/stores/workspace-store.ts +++ b/desktop/src/renderer/stores/workspace-store.ts @@ -17,6 +17,7 @@ import { openTab as openTabIpc, openWorkspace, resizeSplit as resizeSplitIpc, + setFileTreeExpandedPaths as setFileTreeExpandedPathsIpc, setTabKind as setTabKindIpc, selectWorkspaceDirectory, splitTab as splitTabIpc, @@ -26,12 +27,14 @@ import type { PaneId, SplitAxis, SplitPaneId, + SetFileTreeExpandedPathsParams, TabId, TabKind, WorkspaceId, } from "@/shared/ipc"; type WorkspaceRequest = () => Promise; +type FileTreeExpansionFlusher = () => Promise | void; type WorkspaceStore = { error: string | null; @@ -47,7 +50,9 @@ type WorkspaceStore = { closeWorkspace(workspaceId: WorkspaceId): Promise; initializeWorkspaces(): Promise; openTab(paneId: PaneId): void; + registerFileTreeExpansionFlusher(flusher: FileTreeExpansionFlusher): () => void; resizeSplit(splitId: SplitPaneId, ratio: number): void; + saveFileTreeExpandedPaths(params: SetFileTreeExpandedPathsParams): Promise; setTabKind(paneId: PaneId, tabId: TabId, kind: TabKind): void; splitTab( paneId: PaneId, @@ -60,6 +65,9 @@ type WorkspaceStore = { }; export const useWorkspaceStore = create((set, get) => { + const fileTreeExpansionFlushers = new Set(); + const pendingFileTreeExpansionSaves = new Set>(); + function updateFromServer(request: WorkspaceRequest): void { set({ error: null }); @@ -74,6 +82,38 @@ export const useWorkspaceStore = create((set, get) => { }); } + function trackFileTreeExpansionSave(promise: Promise): Promise { + pendingFileTreeExpansionSaves.add(promise); + + void promise.finally(() => { + pendingFileTreeExpansionSaves.delete(promise); + }); + + return promise; + } + + async function flushFileTreeExpansionSaves(): Promise { + const flushPromises: Promise[] = []; + + for (const flusher of fileTreeExpansionFlushers) { + try { + flushPromises.push( + Promise.resolve(flusher()).catch((caughtError: unknown) => { + set({ error: errorMessage(caughtError) }); + }), + ); + } catch (caughtError) { + set({ error: errorMessage(caughtError) }); + } + } + + await Promise.all(flushPromises); + + while (pendingFileTreeExpansionSaves.size > 0) { + await Promise.all(Array.from(pendingFileTreeExpansionSaves)); + } + } + return { error: null, isAddingWorkspace: false, @@ -176,6 +216,13 @@ export const useWorkspaceStore = create((set, get) => { openTabIpc({ workspaceId: activeWorkspace.id, paneId, kind: "blank" }), ); }, + registerFileTreeExpansionFlusher(flusher) { + fileTreeExpansionFlushers.add(flusher); + + return () => { + fileTreeExpansionFlushers.delete(flusher); + }; + }, resizeSplit(splitId, ratio) { const activeWorkspace = activeWorkspaceFrom(get().snapshot); if (!activeWorkspace) { @@ -192,6 +239,15 @@ export const useWorkspaceStore = create((set, get) => { }, ); }, + saveFileTreeExpandedPaths(params) { + const save = setFileTreeExpandedPathsIpc(params) + .then(() => undefined) + .catch((caughtError: unknown) => { + set({ error: errorMessage(caughtError) }); + }); + + return trackFileTreeExpansionSave(save); + }, setTabKind(paneId, tabId, kind) { const activeWorkspace = activeWorkspaceFrom(get().snapshot); if (!activeWorkspace) { @@ -228,10 +284,17 @@ export const useWorkspaceStore = create((set, get) => { const requestId = switchRequestId + 1; const previousSnapshot = snapshot; + set({ error: null, switchRequestId: requestId }); + + await flushFileTreeExpansionSaves(); + + if (get().switchRequestId !== requestId) { + return; + } + set({ error: null, snapshot: snapshot ? { ...snapshot, activeWorkspaceId: workspaceId } : snapshot, - switchRequestId: requestId, }); try { diff --git a/desktop/src/shared/ipc/envelope.ts b/desktop/src/shared/ipc/envelope.ts index 72129659..1f71a6fb 100644 --- a/desktop/src/shared/ipc/envelope.ts +++ b/desktop/src/shared/ipc/envelope.ts @@ -1,4 +1,4 @@ -export type KosmosIpcDomain = "workspace" | "pane" | "tab"; +export type KosmosIpcDomain = "workspace" | "pane" | "tab" | "fileTree"; export type KosmosIpcParams = Record; diff --git a/desktop/src/shared/ipc/file-tree.ts b/desktop/src/shared/ipc/file-tree.ts new file mode 100644 index 00000000..19d3c87a --- /dev/null +++ b/desktop/src/shared/ipc/file-tree.ts @@ -0,0 +1,18 @@ +import type { TabId, WorkspaceId } from "./ids"; + +export type GetFileTreeParams = { + workspaceId?: WorkspaceId | null; + tabId?: TabId | null; +}; + +export type SetFileTreeExpandedPathsParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + expandedPaths: string[]; +}; + +export type FileTreeSnapshot = { + root: string; + paths: string[]; + expandedPaths: string[]; +}; diff --git a/desktop/src/shared/ipc/index.ts b/desktop/src/shared/ipc/index.ts index e08f343b..2b5d263d 100644 --- a/desktop/src/shared/ipc/index.ts +++ b/desktop/src/shared/ipc/index.ts @@ -1,4 +1,5 @@ export type * from "./envelope"; +export type * from "./file-tree"; export type * from "./ids"; export type * from "./pane"; export type * from "./tab"; diff --git a/server/src/ipc/messages/envelope.rs b/server/src/ipc/messages/envelope.rs index dad01c1f..5a449b15 100644 --- a/server/src/ipc/messages/envelope.rs +++ b/server/src/ipc/messages/envelope.rs @@ -62,6 +62,7 @@ pub(crate) enum Domain { Workspace, Pane, Tab, + FileTree, } #[derive(Debug, Serialize)] diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs new file mode 100644 index 00000000..c82e241c --- /dev/null +++ b/server/src/ipc/messages/file_tree.rs @@ -0,0 +1,38 @@ +use core::file_tree::FileTree; +use serde::{Deserialize, Serialize}; + +use super::pane::WorkspaceIdParam; +use super::tab::TabIdParam; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GetFileTreeParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SetFileTreeExpandedPathsParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) expanded_paths: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileTreeSnapshot { + root: String, + paths: Vec, + expanded_paths: Vec, +} + +impl FileTreeSnapshot { + pub(crate) fn from_file_tree(file_tree: &FileTree) -> Self { + Self { + root: file_tree.root().to_string_lossy().into_owned(), + paths: file_tree.paths().to_vec(), + expanded_paths: file_tree.expanded_paths().to_vec(), + } + } +} diff --git a/server/src/ipc/messages/mod.rs b/server/src/ipc/messages/mod.rs index 3e35c43e..c63601b2 100644 --- a/server/src/ipc/messages/mod.rs +++ b/server/src/ipc/messages/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod envelope; +pub(crate) mod file_tree; pub(crate) mod pane; pub(crate) mod tab; pub(crate) mod workspace; diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs new file mode 100644 index 00000000..270e8116 --- /dev/null +++ b/server/src/ipc/router/file_tree.rs @@ -0,0 +1,64 @@ +use core::file_tree::FileTreeError; + +use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; +use super::super::messages::file_tree::{ + FileTreeSnapshot, GetFileTreeParams, SetFileTreeExpandedPathsParams, +}; +use super::{parse_params, unsupported_action}; + +pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match request.action.as_str() { + "get" => get_file_tree(state, request), + "setExpandedPaths" => set_expanded_paths(state, request), + _ => unsupported_action(request), + } +} + +fn get_file_tree(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.file_tree( + params.workspace_id.map(Into::into), + params.tab_id.map(Into::into), + ) { + Ok(file_tree) => { + ServerMessage::ok(request.id, FileTreeSnapshot::from_file_tree(&file_tree)) + } + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn set_expanded_paths(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => { + let updated = state.set_file_tree_expanded_paths( + params.workspace_id.map(Into::into), + params.tab_id.into(), + params.expanded_paths, + ); + + if updated { + ServerMessage::ok(request.id, true) + } else { + ServerMessage::error( + request.id, + "file_tree.tab_not_found", + "file tree tab does not exist", + ) + } + } + Err(response) => response, + } +} + +fn file_tree_error_code(error: &FileTreeError) -> &'static str { + match error { + FileTreeError::WorkspaceNotFound => "file_tree.workspace_not_found", + FileTreeError::TabNotFound => "file_tree.tab_not_found", + FileTreeError::RootNotDirectory(_) => "file_tree.root_not_directory", + FileTreeError::Io { .. } => "file_tree.read_failed", + } +} diff --git a/server/src/ipc/router/mod.rs b/server/src/ipc/router/mod.rs index 2551eba8..7ecca30a 100644 --- a/server/src/ipc/router/mod.rs +++ b/server/src/ipc/router/mod.rs @@ -1,3 +1,4 @@ +mod file_tree; mod pane; mod tab; mod workspace; @@ -12,6 +13,7 @@ pub(crate) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve Domain::Workspace => workspace::route(state, request), Domain::Pane => pane::route(state, request), Domain::Tab => tab::route(state, request), + Domain::FileTree => file_tree::route(state, request), } } diff --git a/server/src/ipc/transport/connection.rs b/server/src/ipc/transport/connection.rs index fc62a07b..6b8ab8de 100644 --- a/server/src/ipc/transport/connection.rs +++ b/server/src/ipc/transport/connection.rs @@ -68,6 +68,7 @@ fn persistence_mode(request: &RequestEnvelope) -> PersistenceMode { match (request.domain, request.action.as_str()) { (Domain::Workspace, "list") => PersistenceMode::None, (Domain::Workspace, "activate") => PersistenceMode::ActiveWorkspace, + (Domain::FileTree, "get") => PersistenceMode::None, _ => PersistenceMode::Full, } } From 82c40d9ac63df86058dd95fd5b56b62e9a3028f1 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 03:28:19 -0300 Subject: [PATCH 29/98] feat: add file tree filesystem actions --- core/src/file_tree.rs | 705 +++++++++++++++- core/src/state.rs | 99 ++- desktop/src/main/main.ts | 10 +- desktop/src/preload/preload.ts | 3 + .../renderer/components/tabs/file-tree.tsx | 781 +++++++++++++++++- desktop/src/renderer/ipc/file-tree.ts | 51 +- desktop/src/renderer/ipc/transport.ts | 4 + desktop/src/shared/ipc/envelope.ts | 1 + desktop/src/shared/ipc/file-tree.ts | 46 ++ server/src/ipc/messages/file_tree.rs | 82 +- server/src/ipc/router/file_tree.rs | 140 +++- 11 files changed, 1887 insertions(+), 35 deletions(-) diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs index 7b41ef4d..60f91024 100644 --- a/core/src/file_tree.rs +++ b/core/src/file_tree.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::error::Error as StdError; use std::fmt; use std::fs; +use std::fs::OpenOptions; use std::io; use std::path::{Path, PathBuf}; @@ -10,6 +11,12 @@ use crate::tree::{TabId, WorkspaceId}; pub type Result = std::result::Result; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileTreeEntryKind { + Directory, + File, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct FileTree { root: PathBuf, @@ -55,6 +62,148 @@ impl FileTree { pub fn expanded_paths(&self) -> &[String] { &self.expanded_paths } + + pub fn create_entry( + root: impl AsRef, + parent_path: Option<&str>, + name: &str, + kind: FileTreeEntryKind, + ) -> Result { + let root = root.as_ref(); + ensure_directory(root)?; + let parent = resolve_existing_directory(root, parent_path)?; + let name = normalize_entry_name(name)?; + let destination = parent.join(name); + + ensure_missing(&destination)?; + + match kind { + FileTreeEntryKind::Directory => fs::create_dir(&destination) + .map_err(|error| io_error(destination.clone(), error))?, + FileTreeEntryKind::File => { + OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .map_err(|error| io_error(destination.clone(), error))?; + } + } + + Ok(destination) + } + + pub fn rename_entry( + root: impl AsRef, + source_path: &str, + destination_path: &str, + ) -> Result { + let root = root.as_ref(); + ensure_directory(root)?; + let source = resolve_existing_entry(root, source_path)?; + let destination_relative_path = + normalize_relative_path(destination_path, PathUsage::Entry)?; + let destination = root.join(&destination_relative_path); + + if source == destination { + return Ok(destination); + } + + ensure_existing_parent_directory(root, &destination_relative_path)?; + ensure_missing(&destination)?; + ensure_not_descendant_move(&source, &destination)?; + fs::rename(&source, &destination).map_err(|error| io_error(source, error))?; + + Ok(destination) + } + + pub fn move_entries( + root: impl AsRef, + source_paths: &[String], + target_directory_path: Option<&str>, + ) -> Result> { + let root = root.as_ref(); + ensure_directory(root)?; + let target_directory = resolve_existing_directory(root, target_directory_path)?; + let moves = prepare_directory_transfers(root, source_paths, &target_directory)?; + let destinations = moves + .iter() + .map(|entry_move| entry_move.destination.clone()) + .collect::>(); + + for entry_move in moves { + if entry_move.source == entry_move.destination { + continue; + } + + fs::rename(&entry_move.source, &entry_move.destination) + .map_err(|error| io_error(entry_move.source, error))?; + } + + Ok(destinations) + } + + pub fn copy_entries( + root: impl AsRef, + source_paths: &[String], + target_directory_path: Option<&str>, + ) -> Result> { + let root = root.as_ref(); + ensure_directory(root)?; + let target_directory = resolve_existing_directory(root, target_directory_path)?; + let transfers = prepare_copy_transfers(root, source_paths, &target_directory)?; + let destinations = transfers + .iter() + .map(|entry_move| entry_move.destination.clone()) + .collect::>(); + + for entry_move in transfers { + copy_entry(&entry_move.source, &entry_move.destination)?; + } + + Ok(destinations) + } + + pub fn delete_entry(root: impl AsRef, path: &str) -> Result<()> { + let root = root.as_ref(); + ensure_directory(root)?; + let path = resolve_existing_entry(root, path)?; + + delete_resolved_entry(path) + } + + pub fn delete_entries(root: impl AsRef, paths: &[String]) -> Result<()> { + let root = root.as_ref(); + ensure_directory(root)?; + let paths = prepare_delete_paths(root, paths)?; + + for path in paths { + delete_resolved_entry(path)?; + } + + Ok(()) + } + + pub fn resolve_path(root: impl AsRef, path: Option<&str>) -> Result { + let root = root.as_ref(); + ensure_directory(root)?; + + match path { + Some(path) if !path.trim().is_empty() => resolve_existing_entry(root, path), + _ => Ok(root.to_path_buf()), + } + } +} + +fn delete_resolved_entry(path: PathBuf) -> Result<()> { + let file_type = fs::symlink_metadata(&path) + .map_err(|error| io_error(path.clone(), error))? + .file_type(); + + if file_type.is_dir() { + fs::remove_dir_all(&path).map_err(|error| io_error(path, error)) + } else { + fs::remove_file(&path).map_err(|error| io_error(path, error)) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -88,22 +237,58 @@ impl FileTreeViewState { #[derive(Debug)] pub enum FileTreeError { - Io { path: PathBuf, error: io::Error }, + CannotMoveIntoSelf { + source: PathBuf, + destination: PathBuf, + }, + DirectoryNotFound(PathBuf), + EntryAlreadyExists(PathBuf), + EntryNotFound(PathBuf), + InvalidName(String), + InvalidPath(String), + Io { + path: PathBuf, + error: io::Error, + }, RootNotDirectory(PathBuf), TabNotFound, + UnsupportedEntry(PathBuf), WorkspaceNotFound, } impl fmt::Display for FileTreeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::CannotMoveIntoSelf { + source, + destination, + } => write!( + formatter, + "cannot move {} into {}", + source.display(), + destination.display() + ), + Self::DirectoryNotFound(path) => { + write!(formatter, "directory does not exist: {}", path.display()) + } + Self::EntryAlreadyExists(path) => { + write!(formatter, "entry already exists: {}", path.display()) + } + Self::EntryNotFound(path) => { + write!(formatter, "entry does not exist: {}", path.display()) + } + Self::InvalidName(name) => write!(formatter, "invalid file name: {name}"), + Self::InvalidPath(path) => write!(formatter, "invalid file tree path: {path}"), Self::Io { path, error } => { - write!(formatter, "could not read {}: {error}", path.display()) + write!(formatter, "could not access {}: {error}", path.display()) } Self::RootNotDirectory(path) => { write!(formatter, "{} is not a directory", path.display()) } Self::TabNotFound => formatter.write_str("file tree tab does not exist"), + Self::UnsupportedEntry(path) => { + write!(formatter, "unsupported file tree entry: {}", path.display()) + } Self::WorkspaceNotFound => formatter.write_str("workspace does not exist"), } } @@ -113,7 +298,16 @@ impl StdError for FileTreeError { fn source(&self) -> Option<&(dyn StdError + 'static)> { match self { Self::Io { error, .. } => Some(error), - Self::RootNotDirectory(_) | Self::TabNotFound | Self::WorkspaceNotFound => None, + Self::CannotMoveIntoSelf { .. } + | Self::DirectoryNotFound(_) + | Self::EntryAlreadyExists(_) + | Self::EntryNotFound(_) + | Self::InvalidName(_) + | Self::InvalidPath(_) + | Self::RootNotDirectory(_) + | Self::TabNotFound + | Self::UnsupportedEntry(_) + | Self::WorkspaceNotFound => None, } } } @@ -128,6 +322,328 @@ fn ensure_directory(root: &Path) -> Result<()> { } } +fn resolve_existing_directory(root: &Path, path: Option<&str>) -> Result { + match path { + Some(path) if !path.trim().is_empty() => { + let path = normalize_relative_path(path, PathUsage::Directory)?; + resolve_directory(root, &path) + } + _ => Ok(root.to_path_buf()), + } +} + +fn resolve_existing_entry(root: &Path, path: &str) -> Result { + let path = normalize_relative_path(path, PathUsage::Entry)?; + ensure_existing_parent_directory(root, &path)?; + let resolved_path = root.join(path); + + if resolved_path.exists() { + Ok(resolved_path) + } else { + Err(FileTreeError::EntryNotFound(resolved_path)) + } +} + +fn resolve_directory(root: &Path, path: &Path) -> Result { + let mut current = root.to_path_buf(); + + for segment in path.iter() { + current.push(segment); + let metadata = fs::symlink_metadata(¤t) + .map_err(|error| map_not_found_to_directory_not_found(current.clone(), error))?; + let file_type = metadata.file_type(); + + if !file_type.is_dir() || file_type.is_symlink() { + return Err(FileTreeError::DirectoryNotFound(current)); + } + } + + Ok(current) +} + +fn ensure_existing_parent_directory(root: &Path, path: &Path) -> Result { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()); + resolve_existing_directory(root, parent.map(path_to_str)) +} + +fn ensure_missing(path: &Path) -> Result<()> { + if path.exists() { + Err(FileTreeError::EntryAlreadyExists(path.to_path_buf())) + } else { + Ok(()) + } +} + +fn ensure_not_descendant_move(source: &Path, destination: &Path) -> Result<()> { + if source != destination && destination.starts_with(source) { + Err(FileTreeError::CannotMoveIntoSelf { + source: source.to_path_buf(), + destination: destination.to_path_buf(), + }) + } else { + Ok(()) + } +} + +fn map_not_found_to_directory_not_found(path: PathBuf, error: io::Error) -> FileTreeError { + if error.kind() == io::ErrorKind::NotFound { + FileTreeError::DirectoryNotFound(path) + } else { + io_error(path, error) + } +} + +fn prepare_directory_transfers( + root: &Path, + source_paths: &[String], + target_directory: &Path, +) -> Result> { + if source_paths.is_empty() { + return Err(FileTreeError::InvalidPath("empty selection".to_owned())); + } + + let mut transfers = Vec::new(); + let mut destinations = HashSet::new(); + + for source_path in source_paths { + let source = resolve_existing_entry(root, source_path)?; + let destination = target_directory.join(entry_name(&source)?); + + ensure_not_descendant_move(&source, &destination)?; + + if source != destination { + ensure_missing(&destination)?; + } + + if !destinations.insert(destination.clone()) { + return Err(FileTreeError::EntryAlreadyExists(destination)); + } + + transfers.push(EntryTransfer { + source, + destination, + }); + } + + Ok(transfers) +} + +fn prepare_copy_transfers( + root: &Path, + source_paths: &[String], + target_directory: &Path, +) -> Result> { + if source_paths.is_empty() { + return Err(FileTreeError::InvalidPath("empty selection".to_owned())); + } + + let mut transfers = Vec::new(); + let mut destinations = HashSet::new(); + + for source_path in source_paths { + let source = resolve_existing_entry(root, source_path)?; + let destination = target_directory.join(entry_name(&source)?); + let source_is_directory = fs::symlink_metadata(&source) + .map_err(|error| io_error(source.clone(), error))? + .file_type() + .is_dir(); + + ensure_not_descendant_move(&source, &destination)?; + + let destination = + available_copy_destination(&destination, source_is_directory, &destinations)?; + destinations.insert(destination.clone()); + transfers.push(EntryTransfer { + source, + destination, + }); + } + + Ok(transfers) +} + +fn available_copy_destination( + destination: &Path, + is_directory: bool, + reserved_destinations: &HashSet, +) -> Result { + if !destination.exists() && !reserved_destinations.contains(destination) { + return Ok(destination.to_path_buf()); + } + + for index in 2.. { + let destination = copy_destination_with_suffix(destination, is_directory, index)?; + + if !destination.exists() && !reserved_destinations.contains(&destination) { + return Ok(destination); + } + } + + unreachable!("copy destination suffix search is unbounded") +} + +fn copy_destination_with_suffix( + destination: &Path, + is_directory: bool, + index: usize, +) -> Result { + let name = entry_name(destination)? + .to_str() + .ok_or_else(|| FileTreeError::InvalidPath(destination.to_string_lossy().into_owned()))?; + + Ok(destination.with_file_name(copy_name_with_suffix(name, is_directory, index))) +} + +fn copy_name_with_suffix(name: &str, is_directory: bool, index: usize) -> String { + if is_directory { + return format!("{name} {index}"); + } + + let path = Path::new(name); + let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else { + return format!("{name} {index}"); + }; + + if extension.is_empty() { + return format!("{name} {index}"); + } + + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(name); + + format!("{stem} {index}.{extension}") +} + +fn prepare_delete_paths(root: &Path, source_paths: &[String]) -> Result> { + if source_paths.is_empty() { + return Err(FileTreeError::InvalidPath("empty selection".to_owned())); + } + + let mut paths = source_paths + .iter() + .map(|source_path| resolve_existing_entry(root, source_path)) + .collect::>>()?; + + paths.sort(); + paths.dedup(); + paths.sort_by_key(|path| path.components().count()); + + let mut selected_paths = Vec::new(); + + for path in paths { + if selected_paths + .iter() + .any(|selected| path.starts_with(selected)) + { + continue; + } + + selected_paths.push(path); + } + + Ok(selected_paths) +} + +fn entry_name(path: &Path) -> Result<&std::ffi::OsStr> { + path.file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| FileTreeError::InvalidPath(path.to_string_lossy().into_owned())) +} + +fn copy_entry(source: &Path, destination: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + return Err(FileTreeError::UnsupportedEntry(source.to_path_buf())); + } + + if file_type.is_dir() { + copy_directory(source, destination) + } else if file_type.is_file() { + fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| io_error(source, error)) + } else { + Err(FileTreeError::UnsupportedEntry(source.to_path_buf())) + } +} + +fn copy_directory(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir(destination).map_err(|error| io_error(destination, error))?; + + for entry in fs::read_dir(source).map_err(|error| io_error(source, error))? { + let entry = entry.map_err(|error| io_error(source, error))?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + + copy_entry(&source_path, &destination_path)?; + } + + Ok(()) +} + +fn normalize_entry_name(name: &str) -> Result<&str> { + let name = name.trim(); + + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + { + Err(FileTreeError::InvalidName(name.to_owned())) + } else { + Ok(name) + } +} + +fn normalize_relative_path(path: &str, usage: PathUsage) -> Result { + let normalized_path = match usage { + PathUsage::Directory => path.trim().strip_suffix('/').unwrap_or(path.trim()), + PathUsage::Entry => path.trim().strip_suffix('/').unwrap_or(path.trim()), + }; + + if normalized_path.is_empty() + || normalized_path.starts_with('/') + || normalized_path.contains('\\') + || normalized_path.contains('\0') + { + return Err(FileTreeError::InvalidPath(path.to_owned())); + } + + if normalized_path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return Err(FileTreeError::InvalidPath(path.to_owned())); + } + + Ok(normalized_path.split('/').collect()) +} + +fn path_to_str(path: &Path) -> &str { + path.to_str() + .expect("validated file tree paths must be valid UTF-8") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PathUsage { + Directory, + Entry, +} + +#[derive(Debug)] +struct EntryTransfer { + source: PathBuf, + destination: PathBuf, +} + fn collect_paths( directory: &Path, relative_directory: &str, @@ -324,6 +840,189 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn creates_files_and_directories_under_validated_parent() { + let root = test_root("create"); + fs::create_dir(root.join("src")).expect("parent should be created"); + + let file = FileTree::create_entry(&root, Some("src/"), "main.rs", FileTreeEntryKind::File) + .expect("file should be created"); + let directory = FileTree::create_entry( + &root, + Some("src"), + "components", + FileTreeEntryKind::Directory, + ) + .expect("directory should be created"); + + assert_eq!(file, root.join("src/main.rs")); + assert!(file.is_file()); + assert_eq!(directory, root.join("src/components")); + assert!(directory.is_dir()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_invalid_create_names_and_paths() { + let root = test_root("invalid-create"); + + let invalid_name = + FileTree::create_entry(&root, None, "../outside.txt", FileTreeEntryKind::File) + .expect_err("path-like names should be rejected"); + let invalid_parent = FileTree::create_entry( + &root, + Some("../outside"), + "file.txt", + FileTreeEntryKind::File, + ) + .expect_err("traversal parents should be rejected"); + + assert!(matches!(invalid_name, FileTreeError::InvalidName(_))); + assert!(matches!(invalid_parent, FileTreeError::InvalidPath(_))); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn renames_entries_with_destination_validation() { + let root = test_root("rename"); + fs::create_dir(root.join("src")).expect("parent should be created"); + fs::write(root.join("src/main.rs"), b"fn main() {}").expect("file should be written"); + + let destination = FileTree::rename_entry(&root, "src/main.rs", "src/lib.rs") + .expect("entry should be renamed"); + + assert_eq!(destination, root.join("src/lib.rs")); + assert!(!root.join("src/main.rs").exists()); + assert!(root.join("src/lib.rs").is_file()); + + let invalid_destination = FileTree::rename_entry(&root, "src/lib.rs", "../lib.rs") + .expect_err("traversal destinations should be rejected"); + assert!(matches!(invalid_destination, FileTreeError::InvalidPath(_))); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn moves_entries_to_target_directory() { + let root = test_root("move"); + fs::create_dir(root.join("src")).expect("source parent should be created"); + fs::create_dir(root.join("dest")).expect("target parent should be created"); + fs::write(root.join("src/main.rs"), b"fn main() {}").expect("file should be written"); + + let destinations = + FileTree::move_entries(&root, &["src/main.rs".to_owned()], Some("dest/")) + .expect("entry should move"); + + assert_eq!(destinations, &[root.join("dest/main.rs")]); + assert!(!root.join("src/main.rs").exists()); + assert!(root.join("dest/main.rs").is_file()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copies_directories_recursively() { + let root = test_root("copy"); + fs::create_dir_all(root.join("src/components")).expect("source should be created"); + fs::create_dir(root.join("dest")).expect("target should be created"); + fs::write(root.join("src/components/button.tsx"), b"export {}") + .expect("file should be written"); + + let destinations = FileTree::copy_entries(&root, &["src/".to_owned()], Some("dest")) + .expect("entry should copy"); + + assert_eq!(destinations, &[root.join("dest/src")]); + assert!(root.join("src/components/button.tsx").is_file()); + assert!(root.join("dest/src/components/button.tsx").is_file()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_uses_unique_names_when_destination_exists() { + let root = test_root("copy-collision"); + fs::create_dir_all(root.join("src/components")).expect("source should be created"); + fs::write(root.join("src/components/button.tsx"), b"export {}") + .expect("nested file should be written"); + fs::write(root.join("README.md"), b"readme").expect("file should be written"); + fs::write(root.join("README 2.md"), b"readme copy") + .expect("existing copy should be written"); + + let destinations = + FileTree::copy_entries(&root, &["README.md".to_owned(), "src/".to_owned()], None) + .expect("entries should copy with unique names"); + + assert_eq!( + destinations, + &[root.join("README 3.md"), root.join("src 2")] + ); + assert!(root.join("README.md").is_file()); + assert!(root.join("README 3.md").is_file()); + assert!(root.join("src/components/button.tsx").is_file()); + assert!(root.join("src 2/components/button.tsx").is_file()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn deletes_files_and_directories() { + let root = test_root("delete"); + fs::create_dir_all(root.join("src/components")).expect("directory should be created"); + fs::write(root.join("README.md"), b"readme").expect("file should be written"); + + FileTree::delete_entry(&root, "README.md").expect("file should be deleted"); + FileTree::delete_entry(&root, "src/").expect("directory should be deleted"); + + assert!(!root.join("README.md").exists()); + assert!(!root.join("src").exists()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn deletes_multiple_entries_once_when_selection_contains_descendants() { + let root = test_root("delete-many"); + fs::create_dir_all(root.join("src/components")).expect("directory should be created"); + fs::write(root.join("src/components/button.tsx"), b"export {}") + .expect("nested file should be written"); + fs::write(root.join("README.md"), b"readme").expect("file should be written"); + + FileTree::delete_entries( + &root, + &[ + "src/".to_owned(), + "src/components/button.tsx".to_owned(), + "README.md".to_owned(), + ], + ) + .expect("selected entries should be deleted"); + + assert!(!root.join("src").exists()); + assert!(!root.join("README.md").exists()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolves_only_existing_root_relative_paths() { + let root = test_root("resolve"); + fs::write(root.join("README.md"), b"readme").expect("file should be written"); + + assert_eq!(FileTree::resolve_path(&root, None).unwrap(), root); + assert_eq!( + FileTree::resolve_path(&root, Some("README.md")).unwrap(), + root.join("README.md") + ); + + let missing = FileTree::resolve_path(&root, Some("missing.md")) + .expect_err("missing entries should be rejected"); + assert!(matches!(missing, FileTreeError::EntryNotFound(_))); + + let _ = fs::remove_dir_all(root); + } + fn test_root(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/core/src/state.rs b/core/src/state.rs index a583ed3f..7630c498 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,6 +1,6 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::file_tree::{FileTree, FileTreeError, FileTreeViewState}; +use crate::file_tree::{FileTree, FileTreeEntryKind, FileTreeError, FileTreeViewState}; use crate::tree::{ Pane, PaneId, PaneNode, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, @@ -111,6 +111,81 @@ impl State { true } + pub fn create_file_tree_entry( + &self, + workspace_id: Option, + tab_id: TabId, + parent_path: Option<&str>, + name: &str, + kind: FileTreeEntryKind, + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::create_entry(directory, parent_path, name, kind).map(|_| ()) + } + + pub fn rename_file_tree_entry( + &self, + workspace_id: Option, + tab_id: TabId, + source_path: &str, + destination_path: &str, + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::rename_entry(directory, source_path, destination_path).map(|_| ()) + } + + pub fn move_file_tree_entries( + &self, + workspace_id: Option, + tab_id: TabId, + source_paths: &[String], + target_directory_path: Option<&str>, + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::move_entries(directory, source_paths, target_directory_path).map(|_| ()) + } + + pub fn copy_file_tree_entries( + &self, + workspace_id: Option, + tab_id: TabId, + source_paths: &[String], + target_directory_path: Option<&str>, + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::copy_entries(directory, source_paths, target_directory_path).map(|_| ()) + } + + pub fn delete_file_tree_entry( + &self, + workspace_id: Option, + tab_id: TabId, + path: &str, + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::delete_entry(directory, path) + } + + pub fn delete_file_tree_entries( + &self, + workspace_id: Option, + tab_id: TabId, + paths: &[String], + ) -> Result<(), FileTreeError> { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::delete_entries(directory, paths) + } + + pub fn resolve_file_tree_path( + &self, + workspace_id: Option, + tab_id: TabId, + path: Option<&str>, + ) -> Result { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + FileTree::resolve_path(directory, path) + } + pub fn open_workspace(&mut self, directory: impl Into) -> WorkspaceId { let directory = directory.into(); @@ -447,6 +522,26 @@ impl State { self.tab_kind(workspace_id, tab_id) == Some(&TabKind::FileTree) } + fn file_tree_workspace_directory( + &self, + workspace_id: Option, + tab_id: TabId, + ) -> Result<&Path, FileTreeError> { + let workspace_id = self + .resolve_workspace_id(workspace_id) + .ok_or(FileTreeError::WorkspaceNotFound)?; + let workspace = self + .workspaces + .workspace(workspace_id) + .ok_or(FileTreeError::WorkspaceNotFound)?; + + if !self.is_file_tree_tab(workspace_id, tab_id) { + return Err(FileTreeError::TabNotFound); + } + + Ok(workspace.directory()) + } + fn tab_kind(&self, workspace_id: WorkspaceId, tab_id: TabId) -> Option<&TabKind> { let workspace = self.workspaces.workspace(workspace_id)?; diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 11567d2d..19039062 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, dialog, ipcMain } from "electron"; +import { app, BrowserWindow, Menu, dialog, ipcMain, shell } from "electron"; import fs from "node:fs"; import path from "node:path"; @@ -46,6 +46,14 @@ function registerIpcHandlers(): void { ipcMain.handle("kosmos:window:close", (event) => { BrowserWindow.fromWebContents(event.sender)?.close(); }); + + ipcMain.handle("kosmos:revealPath", (_event, targetPath: unknown) => { + if (typeof targetPath !== "string" || targetPath.length === 0) { + throw new Error("Reveal path must be a non-empty string"); + } + + shell.showItemInFolder(targetPath); + }); } function createMainWindow(): void { diff --git a/desktop/src/preload/preload.ts b/desktop/src/preload/preload.ts index f4c45b73..8e34220b 100644 --- a/desktop/src/preload/preload.ts +++ b/desktop/src/preload/preload.ts @@ -21,6 +21,9 @@ const kosmos: KosmosApi = { closeWindow(): Promise { return ipcRenderer.invoke("kosmos:window:close") as Promise; }, + revealPath(path: string): Promise { + return ipcRenderer.invoke("kosmos:revealPath", path) as Promise; + }, }; contextBridge.exposeInMainWorld("kosmos", kosmos); diff --git a/desktop/src/renderer/components/tabs/file-tree.tsx b/desktop/src/renderer/components/tabs/file-tree.tsx index b593e320..d1b9bb85 100644 --- a/desktop/src/renderer/components/tabs/file-tree.tsx +++ b/desktop/src/renderer/components/tabs/file-tree.tsx @@ -1,15 +1,28 @@ import type { + ContextMenuItem as FileTreeContextMenuItem, + ContextMenuOpenContext as FileTreeContextMenuOpenContext, FileTree as FileTreeModel, FileTreeDirectoryHandle, + FileTreeDropResult, FileTreeItemHandle, + FileTreeRenameEvent, } from "@pierre/trees"; import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react"; +import type { MouseEvent, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; -import { getFileTree } from "@/renderer/ipc"; +import { + copyFileTreeEntries, + createFileTreeEntry, + deleteFileTreeEntries, + getFileTree, + moveFileTreeEntries, + renameFileTreeEntry, + revealFileTreePath, +} from "@/renderer/ipc"; import { errorMessage } from "@/renderer/lib/errors"; import { useWorkspaceStore } from "@/renderer/stores"; -import type { FileTreeSnapshot, TabId, WorkspaceId } from "@/shared/ipc"; +import type { FileTreeEntryKind, FileTreeSnapshot, TabId, WorkspaceId } from "@/shared/ipc"; type FileTreeTabProps = { workspaceId: WorkspaceId; @@ -19,9 +32,29 @@ type FileTreeTabProps = { type FileTreeLoadState = | { status: "loading"; workspaceId: WorkspaceId; tabId: TabId } - | { status: "loaded"; workspaceId: WorkspaceId; tabId: TabId; snapshot: FileTreeSnapshot } + | { + status: "loaded"; + workspaceId: WorkspaceId; + tabId: TabId; + snapshot: FileTreeSnapshot; + revision: number; + } | { status: "error"; workspaceId: WorkspaceId; tabId: TabId; message: string }; +type FileTreeClipboard = { + action: "copy" | "cut"; + paths: string[]; +}; + +type PendingFileTreeCreate = { + kind: FileTreeEntryKind; +}; + +type RootContextMenuPosition = { + x: number; + y: number; +}; + const EXPANSION_SAVE_DELAY_MS = 250; export function FileTreeTab({ workspaceId, tabId, onActivatePane }: FileTreeTabProps) { @@ -30,27 +63,45 @@ export function FileTreeTab({ workspaceId, tabId, onActivatePane }: FileTreeTabP workspaceId, tabId, }); + const requestIdRef = useRef(0); + const revisionRef = useRef(0); - useEffect(() => { - let isCurrent = true; + const loadFileTree = async (targetWorkspaceId: WorkspaceId, targetTabId: TabId, showLoading: boolean) => { + const requestId = requestIdRef.current + 1; - setLoadState({ status: "loading", workspaceId, tabId }); + requestIdRef.current = requestId; - void getFileTree({ workspaceId, tabId }) - .then((snapshot) => { - if (isCurrent) { - setLoadState({ status: "loaded", workspaceId, tabId, snapshot }); - } - }) - .catch((caughtError: unknown) => { - if (isCurrent) { - setLoadState({ status: "error", workspaceId, tabId, message: errorMessage(caughtError) }); - } - }); + if (showLoading) { + setLoadState({ status: "loading", workspaceId: targetWorkspaceId, tabId: targetTabId }); + } - return () => { - isCurrent = false; - }; + try { + const snapshot = await getFileTree({ workspaceId: targetWorkspaceId, tabId: targetTabId }); + + if (requestIdRef.current === requestId) { + revisionRef.current += 1; + setLoadState({ + status: "loaded", + workspaceId: targetWorkspaceId, + tabId: targetTabId, + snapshot, + revision: revisionRef.current, + }); + } + } catch (caughtError: unknown) { + if (requestIdRef.current === requestId) { + setLoadState({ + status: "error", + workspaceId: targetWorkspaceId, + tabId: targetTabId, + message: errorMessage(caughtError), + }); + } + } + }; + + useEffect(() => { + void loadFileTree(workspaceId, tabId, true); }, [workspaceId, tabId]); const currentLoadState: FileTreeLoadState = @@ -66,9 +117,11 @@ export function FileTreeTab({ workspaceId, tabId, onActivatePane }: FileTreeTabP ) : null} {currentLoadState.status === "loaded" ? ( loadFileTree(workspaceId, tabId, false)} /> ) : null}
@@ -79,39 +132,717 @@ function LoadedFileTree({ workspaceId, tabId, snapshot, + onReload, }: { workspaceId: WorkspaceId; tabId: TabId; snapshot: FileTreeSnapshot; + onReload(): Promise; }) { + const [clipboard, setClipboard] = useState(null); + const [rootContextMenuPosition, setRootContextMenuPosition] = + useState(null); + const [hasInlineCreate, setHasInlineCreate] = useState(false); + const pendingCreatesRef = useRef>(new Map()); + const closeRowContextMenuRef = useRef(null); + const runMutation = async (mutation: () => Promise) => { + try { + await mutation(); + } catch (caughtError: unknown) { + window.alert(errorMessage(caughtError)); + } finally { + await onReload(); + } + }; + const moveDroppedEntries = (event: FileTreeDropResult) => { + void runMutation(() => + moveFileTreeEntries({ + workspaceId, + tabId, + sourcePaths: [...event.draggedPaths], + targetDirectoryPath: targetDirectoryPath(event.target.directoryPath), + }), + ); + }; + const renameEntry = (event: FileTreeRenameEvent) => { + const pendingCreate = pendingCreatesRef.current.get(event.sourcePath); + + if (pendingCreate) { + pendingCreatesRef.current.delete(event.sourcePath); + void runMutation(() => + createFileTreeEntry({ + workspaceId, + tabId, + parentPath: parentDirectoryPath(event.destinationPath), + name: treePathBasename(event.destinationPath), + kind: pendingCreate.kind, + }), + ).finally(() => setHasInlineCreate(false)); + return; + } + + void runMutation(() => + renameFileTreeEntry({ + workspaceId, + tabId, + sourcePath: event.sourcePath, + destinationPath: event.destinationPath, + }), + ); + }; const { model } = useFileTree({ + dragAndDrop: { + canDrag: (paths) => paths.length > 0, + canDrop: (event) => event.target.kind === "root" || event.target.directoryPath !== null, + onDropComplete: moveDroppedEntries, + onDropError: (message) => window.alert(message), + }, density: "compact", flattenEmptyDirectories: true, initialExpandedPaths: snapshot.expandedPaths, initialExpansion: "closed", paths: snapshot.paths, + renaming: { + canRename: () => true, + onError: (message) => { + removePendingCreates(); + window.alert(message); + }, + onRename: renameEntry, + }, }); + useEffect(() => { + return model.onMutation("*", (event) => { + if (event.operation === "remove") { + clearPendingCreate(event.path); + } - if (snapshot.paths.length === 0) { - return ; - } + if (event.operation === "batch") { + for (const mutationEvent of event.events) { + if (mutationEvent.operation === "remove") { + clearPendingCreate(mutationEvent.path); + } + } + } + }); + }, [model]); + useEffect(() => { + const shadowRoot = model.getFileTreeContainer()?.shadowRoot; + + if (!shadowRoot) { + return undefined; + } + + const style = document.createElement("style"); + style.dataset.kosmosFileTreeCutFeedback = "true"; + style.textContent = cutFeedbackCss(clipboard); + + if (style.textContent.length === 0) { + return undefined; + } + + shadowRoot.appendChild(style); + + return () => { + style.remove(); + }; + }, [clipboard, model]); + const closeRootContextMenu = () => { + setRootContextMenuPosition(null); + }; + const registerRowContextMenu = (context: FileTreeContextMenuOpenContext) => { + closeRootContextMenu(); + closeRowContextMenuRef.current = context.close; + + return () => { + if (closeRowContextMenuRef.current === context.close) { + closeRowContextMenuRef.current = null; + } + }; + }; + const clearPendingCreate = (path: string) => { + if (pendingCreatesRef.current.delete(renameSourcePath(path))) { + setHasInlineCreate(pendingCreatesRef.current.size > 0); + } + }; + const isPendingCreatePath = (path: string): boolean => { + return pendingCreatesRef.current.has(renameSourcePath(path)); + }; + const removePendingCreate = (path: string): boolean => { + const sourcePath = renameSourcePath(path); + const pendingCreate = pendingCreatesRef.current.get(sourcePath); + + if (!pendingCreate) { + return false; + } + + pendingCreatesRef.current.delete(sourcePath); + setHasInlineCreate(pendingCreatesRef.current.size > 0); + + const placeholderPath = + pendingCreate.kind === "directory" ? `${sourcePath}/` : sourcePath; + if (model.getItem(placeholderPath)) { + model.remove( + placeholderPath, + pendingCreate.kind === "directory" ? { recursive: true } : undefined, + ); + } + + return true; + }; + const removePendingCreates = () => { + for (const [sourcePath, pendingCreate] of [...pendingCreatesRef.current]) { + removePendingCreate(pendingCreate.kind === "directory" ? `${sourcePath}/` : sourcePath); + } + }; + const startInlineCreate = (kind: FileTreeEntryKind, parentPath: string | null) => { + const placeholderPath = nextUntitledPath(model, parentPath, kind); + const sourcePath = renameSourcePath(placeholderPath); + + pendingCreatesRef.current.set(sourcePath, { kind }); + setHasInlineCreate(true); + + const parent = parentPath ? model.getItem(parentPath) : null; + if (isDirectoryHandle(parent)) { + parent.expand(); + } + + try { + model.add(placeholderPath); + + if (!model.startRenaming(placeholderPath, { removeIfCanceled: true })) { + pendingCreatesRef.current.delete(sourcePath); + model.remove(placeholderPath, kind === "directory" ? { recursive: true } : undefined); + setHasInlineCreate(false); + } + } catch (caughtError: unknown) { + pendingCreatesRef.current.delete(sourcePath); + setHasInlineCreate(false); + window.alert(errorMessage(caughtError)); + } + }; + const openRootContextMenu = (event: MouseEvent) => { + if (event.defaultPrevented || contextMenuStartedOnTreeItem(event)) { + closeRootContextMenu(); + return; + } + + event.preventDefault(); + closeRowContextMenuRef.current?.({ restoreFocus: false }); + setRootContextMenuPosition({ x: event.clientX, y: event.clientY }); + }; + + useEffect(() => { + if (!rootContextMenuPosition) { + return undefined; + } + + const closeOnPointerDown = () => { + closeRootContextMenu(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeRootContextMenu(); + } + }; + + window.addEventListener("pointerdown", closeOnPointerDown); + window.addEventListener("keydown", closeOnEscape); + + return () => { + window.removeEventListener("pointerdown", closeOnPointerDown); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [rootContextMenuPosition]); return ( - <> +
( + + )} /> + {snapshot.paths.length === 0 && !hasInlineCreate ? ( +
+ +
+ ) : null} - + {rootContextMenuPosition ? ( + + ) : null} +
+ ); +} + +function RootFileTreeContextMenu({ + clipboard, + position, + tabId, + workspaceId, + onClipboardChange, + onClose, + onCreateInline, + onMutation, +}: { + clipboard: FileTreeClipboard | null; + position: RootContextMenuPosition; + tabId: TabId; + workspaceId: WorkspaceId; + onClipboardChange(clipboard: FileTreeClipboard | null): void; + onClose(): void; + onCreateInline(kind: FileTreeEntryKind, parentPath: string | null): void; + onMutation(mutation: () => Promise): Promise; +}) { + const closeAndRun = (mutation: () => Promise) => { + onClose(); + void onMutation(mutation); + }; + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + onClose(); + } + }} + onPointerDown={(event) => { + event.stopPropagation(); + }} + > + { + onClose(); + onCreateInline("file", null); + }} + > + New file + + { + onClose(); + onCreateInline("directory", null); + }} + > + New folder + + + { + if (!clipboard) { + return; + } + + closeAndRun(async () => { + if (clipboard.action === "copy") { + await copyFileTreeEntries({ + workspaceId, + tabId, + sourcePaths: clipboard.paths, + targetDirectoryPath: null, + }); + } else { + await moveFileTreeEntries({ + workspaceId, + tabId, + sourcePaths: clipboard.paths, + targetDirectoryPath: null, + }); + onClipboardChange(null); + } + }); + }} + > + Paste + + + { + onClose(); + void revealFileTreePath({ workspaceId, tabId }).catch((caughtError: unknown) => + window.alert(errorMessage(caughtError)), + ); + }} + > + Reveal in file manager + +
); } +function FileTreeContextMenu({ + clipboard, + context, + item, + model, + tabId, + workspaceId, + onClipboardChange, + onCreateInline, + onIsPendingCreate, + onRegisterContextMenu, + onRemovePendingCreate, + onMutation, +}: { + clipboard: FileTreeClipboard | null; + context: FileTreeContextMenuOpenContext; + item: FileTreeContextMenuItem; + model: FileTreeModel; + tabId: TabId; + workspaceId: WorkspaceId; + onClipboardChange(clipboard: FileTreeClipboard | null): void; + onCreateInline(kind: FileTreeEntryKind, parentPath: string | null): void; + onIsPendingCreate(path: string): boolean; + onRegisterContextMenu(context: FileTreeContextMenuOpenContext): () => void; + onRemovePendingCreate(path: string): boolean; + onMutation(mutation: () => Promise): Promise; +}) { + const itemTargetDirectoryPath = targetDirectoryPathForItem(item); + const itemIsPendingCreate = onIsPendingCreate(item.path); + const selectedPaths = selectedOrItemPaths(model, item.path); + const selectedCount = selectedPaths.length; + const selectedPendingPaths = selectedPaths.filter(onIsPendingCreate); + const selectedRealPaths = selectedPaths.filter((path) => !onIsPendingCreate(path)); + const hasPendingSelection = selectedPendingPaths.length > 0; + const closeAndRun = (mutation: () => Promise) => { + context.close(); + void onMutation(mutation); + }; + + useEffect(() => onRegisterContextMenu(context), [context, onRegisterContextMenu]); + + return ( +
{ + if (event.key === "Escape") { + context.close(); + } + }} + > + { + context.close({ restoreFocus: false }); + onCreateInline("file", itemTargetDirectoryPath); + }} + > + New file + + { + context.close({ restoreFocus: false }); + onCreateInline("directory", itemTargetDirectoryPath); + }} + > + New folder + + + { + context.close({ restoreFocus: false }); + model.startRenaming(item.path); + }} + > + Rename + + { + onClipboardChange({ action: "copy", paths: selectedPaths }); + context.close(); + }} + > + {selectedCount === 1 ? "Copy" : `Copy ${selectedCount} items`} + + { + onClipboardChange({ action: "cut", paths: selectedPaths }); + context.close(); + }} + > + {selectedCount === 1 ? "Cut" : `Cut ${selectedCount} items`} + + { + if (!clipboard) { + return; + } + + closeAndRun(async () => { + if (clipboard.action === "copy") { + await copyFileTreeEntries({ + workspaceId, + tabId, + sourcePaths: clipboard.paths, + targetDirectoryPath: itemTargetDirectoryPath, + }); + } else { + await moveFileTreeEntries({ + workspaceId, + tabId, + sourcePaths: clipboard.paths, + targetDirectoryPath: itemTargetDirectoryPath, + }); + onClipboardChange(null); + } + }); + }} + > + Paste + + + { + context.close(); + void revealFileTreePath({ workspaceId, tabId, path: item.path }).catch( + (caughtError: unknown) => window.alert(errorMessage(caughtError)), + ); + }} + > + Reveal in file manager + + + { + if (selectedRealPaths.length === 0) { + context.close(); + selectedPendingPaths.forEach(onRemovePendingCreate); + return; + } + + const deleteCount = selectedRealPaths.length + selectedPendingPaths.length; + const message = + deleteCount === 1 ? `Delete ${item.name}?` : `Delete ${deleteCount} items?`; + + if (!window.confirm(message)) { + context.close(); + return; + } + + closeAndRun(async () => { + selectedPendingPaths.forEach(onRemovePendingCreate); + await deleteFileTreeEntries({ workspaceId, tabId, paths: selectedRealPaths }); + }); + }} + > + {selectedCount === 1 ? "Delete" : `Delete ${selectedCount} items`} + +
+ ); +} + +function FileTreeMenuItem({ + children, + disabled = false, + variant = "default", + onSelect, +}: { + children: ReactNode; + disabled?: boolean; + variant?: "default" | "destructive"; + onSelect(): void; +}) { + return ( + + ); +} + +function FileTreeMenuSeparator() { + return
; +} + +function selectedOrItemPaths(model: FileTreeModel, itemPath: string): string[] { + const selectedPaths = model.getSelectedPaths(); + const paths = selectedPaths.includes(itemPath) ? [...selectedPaths] : [itemPath]; + + return normalizeSelectedTreePaths(paths); +} + +function normalizeSelectedTreePaths(paths: string[]): string[] { + const sortedPaths = [...new Set(paths)].sort((left, right) => { + const depthDifference = treePathDepth(left) - treePathDepth(right); + + return depthDifference === 0 ? left.localeCompare(right) : depthDifference; + }); + const normalizedPaths: string[] = []; + + for (const path of sortedPaths) { + if (normalizedPaths.some((selectedPath) => treePathContains(selectedPath, path))) { + continue; + } + + normalizedPaths.push(path); + } + + return normalizedPaths; +} + +function treePathContains(parentPath: string, childPath: string): boolean { + return parentPath.endsWith("/") && childPath !== parentPath && childPath.startsWith(parentPath); +} + +function treePathDepth(path: string): number { + return renameSourcePath(path).split("/").length; +} + +function targetDirectoryPathForItem(item: FileTreeContextMenuItem): string | null { + if (item.kind === "directory") { + return targetDirectoryPath(item.path); + } + + return parentDirectoryPath(item.path); +} + +function targetDirectoryPath(path: string | null): string | null { + return path && path.length > 0 ? path : null; +} + +function cutFeedbackCss(clipboard: FileTreeClipboard | null): string { + if (clipboard?.action !== "cut") { + return ""; + } + + return clipboard.paths + .map((path) => { + const selector = `[data-type="item"][data-item-path=${cssString(path)}]`; + + return `${selector}{opacity:.45}${selector} [data-item-section="content"]{text-decoration:line-through}`; + }) + .join("\n"); +} + +function cssString(value: string): string { + return JSON.stringify(value); +} + +function contextMenuStartedOnTreeItem(event: MouseEvent): boolean { + for (const entry of event.nativeEvent.composedPath()) { + if (!(entry instanceof HTMLElement)) { + continue; + } + + if ( + entry.dataset.type === "item" || + entry.dataset.type === "context-menu-anchor" || + entry.dataset.type === "context-menu-trigger" || + entry.dataset.itemPath !== undefined + ) { + return true; + } + } + + return false; +} + +function nextUntitledPath( + model: FileTreeModel, + parentPath: string | null, + kind: FileTreeEntryKind, +): string { + for (let index = 1; ; index += 1) { + const name = index === 1 ? "untitled" : `untitled ${index}`; + const path = placeholderPath(parentPath, name, kind); + + if (!model.getItem(path)) { + return path; + } + } +} + +function placeholderPath( + parentPath: string | null, + name: string, + kind: FileTreeEntryKind, +): string { + const path = parentPath ? `${parentPath}${name}` : name; + + return kind === "directory" ? `${path}/` : path; +} + +function renameSourcePath(path: string): string { + return path.endsWith("/") ? path.slice(0, -1) : path; +} + +function treePathBasename(path: string): string { + const normalizedPath = renameSourcePath(path); + const separatorIndex = normalizedPath.lastIndexOf("/"); + + return separatorIndex < 0 ? normalizedPath : normalizedPath.slice(separatorIndex + 1); +} + +function parentDirectoryPath(path: string): string | null { + const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path; + const separatorIndex = normalizedPath.lastIndexOf("/"); + + if (separatorIndex < 0) { + return null; + } + + return `${normalizedPath.slice(0, separatorIndex)}/`; +} function FileTreeExpansionPersistence({ model, diff --git a/desktop/src/renderer/ipc/file-tree.ts b/desktop/src/renderer/ipc/file-tree.ts index 6dee33a8..b191e075 100644 --- a/desktop/src/renderer/ipc/file-tree.ts +++ b/desktop/src/renderer/ipc/file-tree.ts @@ -1,10 +1,17 @@ import type { + CreateFileTreeEntryParams, + DeleteFileTreeEntriesParams, + DeleteFileTreeEntryParams, + FileTreeResolvedPath, FileTreeSnapshot, GetFileTreeParams, + RenameFileTreeEntryParams, + ResolveFileTreePathParams, SetFileTreeExpandedPathsParams, + TransferFileTreeEntriesParams, } from "@/shared/ipc"; -import { requestServer } from "./transport"; +import { revealPath, requestServer } from "./transport"; const DOMAIN = "fileTree"; @@ -18,8 +25,50 @@ export function setFileTreeExpandedPaths( return requestServer(DOMAIN, "setExpandedPaths", params); } +export function createFileTreeEntry(params: CreateFileTreeEntryParams): Promise { + return requestServer(DOMAIN, "createEntry", params); +} + +export function renameFileTreeEntry(params: RenameFileTreeEntryParams): Promise { + return requestServer(DOMAIN, "renameEntry", params); +} + +export function moveFileTreeEntries(params: TransferFileTreeEntriesParams): Promise { + return requestServer(DOMAIN, "moveEntries", params); +} + +export function copyFileTreeEntries(params: TransferFileTreeEntriesParams): Promise { + return requestServer(DOMAIN, "copyEntries", params); +} + +export function deleteFileTreeEntry(params: DeleteFileTreeEntryParams): Promise { + return requestServer(DOMAIN, "deleteEntry", params); +} + +export function deleteFileTreeEntries(params: DeleteFileTreeEntriesParams): Promise { + return requestServer(DOMAIN, "deleteEntries", params); +} + +export function resolveFileTreePath( + params: ResolveFileTreePathParams, +): Promise { + return requestServer(DOMAIN, "resolvePath", params); +} + +export async function revealFileTreePath(params: ResolveFileTreePathParams): Promise { + const resolvedPath = await resolveFileTreePath(params); + await revealPath(resolvedPath.path); +} + export type { + CreateFileTreeEntryParams, + DeleteFileTreeEntriesParams, + DeleteFileTreeEntryParams, + FileTreeResolvedPath, FileTreeSnapshot, GetFileTreeParams, + RenameFileTreeEntryParams, + ResolveFileTreePathParams, SetFileTreeExpandedPathsParams, + TransferFileTreeEntriesParams, } from "@/shared/ipc"; diff --git a/desktop/src/renderer/ipc/transport.ts b/desktop/src/renderer/ipc/transport.ts index d5604139..09cc25b1 100644 --- a/desktop/src/renderer/ipc/transport.ts +++ b/desktop/src/renderer/ipc/transport.ts @@ -28,6 +28,10 @@ export function closeWindow(): Promise { return kosmosApi().closeWindow(); } +export function revealPath(path: string): Promise { + return kosmosApi().revealPath(path); +} + function kosmosApi() { if (!window.kosmos) { throw new Error("Electron preload did not expose the Kosmos IPC API."); diff --git a/desktop/src/shared/ipc/envelope.ts b/desktop/src/shared/ipc/envelope.ts index 1f71a6fb..21b39dcc 100644 --- a/desktop/src/shared/ipc/envelope.ts +++ b/desktop/src/shared/ipc/envelope.ts @@ -28,4 +28,5 @@ export type KosmosApi = { minimizeWindow(): Promise; toggleMaximizeWindow(): Promise; closeWindow(): Promise; + revealPath(path: string): Promise; }; diff --git a/desktop/src/shared/ipc/file-tree.ts b/desktop/src/shared/ipc/file-tree.ts index 19d3c87a..0aa66f77 100644 --- a/desktop/src/shared/ipc/file-tree.ts +++ b/desktop/src/shared/ipc/file-tree.ts @@ -11,6 +11,52 @@ export type SetFileTreeExpandedPathsParams = { expandedPaths: string[]; }; +export type FileTreeEntryKind = "directory" | "file"; + +export type CreateFileTreeEntryParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + parentPath?: string | null; + name: string; + kind: FileTreeEntryKind; +}; + +export type RenameFileTreeEntryParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + sourcePath: string; + destinationPath: string; +}; + +export type TransferFileTreeEntriesParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + sourcePaths: string[]; + targetDirectoryPath?: string | null; +}; + +export type DeleteFileTreeEntryParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + path: string; +}; + +export type DeleteFileTreeEntriesParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + paths: string[]; +}; + +export type ResolveFileTreePathParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + path?: string | null; +}; + +export type FileTreeResolvedPath = { + path: string; +}; + export type FileTreeSnapshot = { root: string; paths: string[]; diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs index c82e241c..a601e7a1 100644 --- a/server/src/ipc/messages/file_tree.rs +++ b/server/src/ipc/messages/file_tree.rs @@ -1,4 +1,4 @@ -use core::file_tree::FileTree; +use core::file_tree::{FileTree, FileTreeEntryKind}; use serde::{Deserialize, Serialize}; use super::pane::WorkspaceIdParam; @@ -19,6 +19,74 @@ pub(crate) struct SetFileTreeExpandedPathsParams { pub(crate) expanded_paths: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) parent_path: Option, + pub(crate) name: String, + pub(crate) kind: FileTreeEntryKindParam, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FileTreeEntryKindParam { + Directory, + File, +} + +impl From for FileTreeEntryKind { + fn from(kind: FileTreeEntryKindParam) -> Self { + match kind { + FileTreeEntryKindParam::Directory => Self::Directory, + FileTreeEntryKindParam::File => Self::File, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RenameFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) source_path: String, + pub(crate) destination_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TransferFileTreeEntriesParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) source_paths: Vec, + pub(crate) target_directory_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteFileTreeEntryParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteFileTreeEntriesParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) paths: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResolveFileTreePathParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) path: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct FileTreeSnapshot { @@ -27,6 +95,12 @@ pub(crate) struct FileTreeSnapshot { expanded_paths: Vec, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileTreeResolvedPath { + path: String, +} + impl FileTreeSnapshot { pub(crate) fn from_file_tree(file_tree: &FileTree) -> Self { Self { @@ -36,3 +110,9 @@ impl FileTreeSnapshot { } } } + +impl FileTreeResolvedPath { + pub(crate) fn new(path: impl Into) -> Self { + Self { path: path.into() } + } +} diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs index 270e8116..f6a28e88 100644 --- a/server/src/ipc/router/file_tree.rs +++ b/server/src/ipc/router/file_tree.rs @@ -2,7 +2,9 @@ use core::file_tree::FileTreeError; use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; use super::super::messages::file_tree::{ - FileTreeSnapshot, GetFileTreeParams, SetFileTreeExpandedPathsParams, + CreateFileTreeEntryParams, DeleteFileTreeEntriesParams, DeleteFileTreeEntryParams, + FileTreeResolvedPath, FileTreeSnapshot, GetFileTreeParams, RenameFileTreeEntryParams, + ResolveFileTreePathParams, SetFileTreeExpandedPathsParams, TransferFileTreeEntriesParams, }; use super::{parse_params, unsupported_action}; @@ -10,6 +12,13 @@ pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve match request.action.as_str() { "get" => get_file_tree(state, request), "setExpandedPaths" => set_expanded_paths(state, request), + "createEntry" => create_entry(state, request), + "renameEntry" => rename_entry(state, request), + "moveEntries" => move_entries(state, request), + "copyEntries" => copy_entries(state, request), + "deleteEntry" => delete_entry(state, request), + "deleteEntries" => delete_entries(state, request), + "resolvePath" => resolve_path(state, request), _ => unsupported_action(request), } } @@ -54,11 +63,138 @@ fn set_expanded_paths(state: &mut core::State, request: &RequestEnvelope) -> Ser } } +fn create_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.create_file_tree_entry( + params.workspace_id.map(Into::into), + params.tab_id.into(), + params.parent_path.as_deref(), + ¶ms.name, + params.kind.into(), + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn rename_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.rename_file_tree_entry( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.source_path, + ¶ms.destination_path, + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn move_entries(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.move_file_tree_entries( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.source_paths, + params.target_directory_path.as_deref(), + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn copy_entries(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.copy_file_tree_entries( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.source_paths, + params.target_directory_path.as_deref(), + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn delete_entry(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.delete_file_tree_entry( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.path, + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn delete_entries(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.delete_file_tree_entries( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.paths, + ) { + Ok(()) => ServerMessage::ok(request.id, true), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + +fn resolve_path(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.resolve_file_tree_path( + params.workspace_id.map(Into::into), + params.tab_id.into(), + params.path.as_deref(), + ) { + Ok(path) => ServerMessage::ok( + request.id, + FileTreeResolvedPath::new(path.to_string_lossy().into_owned()), + ), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + fn file_tree_error_code(error: &FileTreeError) -> &'static str { match error { FileTreeError::WorkspaceNotFound => "file_tree.workspace_not_found", FileTreeError::TabNotFound => "file_tree.tab_not_found", FileTreeError::RootNotDirectory(_) => "file_tree.root_not_directory", - FileTreeError::Io { .. } => "file_tree.read_failed", + FileTreeError::InvalidPath(_) => "file_tree.invalid_path", + FileTreeError::InvalidName(_) => "file_tree.invalid_name", + FileTreeError::EntryNotFound(_) => "file_tree.entry_not_found", + FileTreeError::DirectoryNotFound(_) => "file_tree.directory_not_found", + FileTreeError::EntryAlreadyExists(_) => "file_tree.entry_already_exists", + FileTreeError::CannotMoveIntoSelf { .. } => "file_tree.cannot_move_into_self", + FileTreeError::UnsupportedEntry(_) => "file_tree.unsupported_entry", + FileTreeError::Io { .. } => "file_tree.access_failed", } } From d29466f4c75a4a9648c1b0f9cd662a1ceec6f9e8 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 13:03:43 -0300 Subject: [PATCH 30/98] fix: lift file tree context menu --- .../renderer/components/tabs/file-tree.tsx | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/desktop/src/renderer/components/tabs/file-tree.tsx b/desktop/src/renderer/components/tabs/file-tree.tsx index d1b9bb85..f4719ac2 100644 --- a/desktop/src/renderer/components/tabs/file-tree.tsx +++ b/desktop/src/renderer/components/tabs/file-tree.tsx @@ -10,6 +10,7 @@ import type { import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react"; import type { MouseEvent, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { copyFileTreeEntries, @@ -362,20 +363,23 @@ function LoadedFileTree({ className="h-full min-h-0 w-full overflow-hidden bg-card text-card-foreground [--trees-bg-muted-override:var(--accent)] [--trees-bg-override:var(--card)] [--trees-border-color-override:var(--border)] [--trees-fg-muted-override:var(--muted-foreground)] [--trees-fg-override:var(--card-foreground)] [--trees-focus-ring-color-override:var(--ring)] [--trees-input-bg-override:var(--input)] [--trees-item-row-gap-override:6px] [--trees-padding-inline-override:0px] [--trees-scrollbar-gutter-override:0px] [--trees-search-bg-override:var(--input)] [--trees-search-fg-override:var(--foreground)] [--trees-selected-bg-override:var(--accent)] [--trees-selected-fg-override:var(--accent-foreground)]" style={{ height: "100%" }} renderContextMenu={(item, context) => ( - + createPortal( + , + document.body, + ) )} /> {snapshot.paths.length === 0 && !hasInlineCreate ? ( @@ -434,7 +438,7 @@ function RootFileTreeContextMenu({ data-file-tree-context-menu-root="true" role="menu" tabIndex={-1} - className="fixed z-50 min-w-40 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none" + className="fixed z-[1000] min-w-40 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none" style={{ left: position.x, top: position.y }} onContextMenu={(event) => { event.preventDefault(); @@ -555,7 +559,8 @@ function FileTreeContextMenu({ data-file-tree-context-menu-root="true" role="menu" tabIndex={-1} - className="z-50 min-w-40 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none" + className="fixed z-[1000] min-w-40 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none" + style={{ left: context.anchorRect.left, top: context.anchorRect.top }} onKeyDown={(event) => { if (event.key === "Escape") { context.close(); From 6a598c8e975aedb509f41758b4b23e7339cc76ac Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 13:31:34 -0300 Subject: [PATCH 31/98] chore: move assets to desktop project and set electron icon --- README.md | 3 +-- {assets => desktop/assets}/brand/kosmos-icon.svg | 0 {assets => desktop/assets}/brand/kosmos-logo.svg | 0 {assets => desktop/assets}/brand/kosmos-text.svg | 0 {assets => desktop/assets}/icon/icon-128.png | Bin {assets => desktop/assets}/icon/icon-16.png | Bin {assets => desktop/assets}/icon/icon-256.png | Bin {assets => desktop/assets}/icon/icon-32.png | Bin {assets => desktop/assets}/icon/icon-48.png | Bin {assets => desktop/assets}/icon/icon-512.png | Bin {assets => desktop/assets}/icon/icon-64.png | Bin {assets => desktop/assets}/icon/icon.svg | 0 {assets => desktop/assets}/langs/astro.svg | 0 {assets => desktop/assets}/langs/astro_light.svg | 0 {assets => desktop/assets}/langs/bash.svg | 0 {assets => desktop/assets}/langs/bash_light.svg | 0 {assets => desktop/assets}/langs/bun.svg | 0 {assets => desktop/assets}/langs/c.svg | 0 {assets => desktop/assets}/langs/cpp.svg | 0 {assets => desktop/assets}/langs/csharp.svg | 0 {assets => desktop/assets}/langs/css.svg | 0 {assets => desktop/assets}/langs/dart.svg | 0 {assets => desktop/assets}/langs/docker.svg | 0 {assets => desktop/assets}/langs/dotenv.svg | 0 {assets => desktop/assets}/langs/git.svg | 0 {assets => desktop/assets}/langs/go.svg | 0 {assets => desktop/assets}/langs/go_light.svg | 0 {assets => desktop/assets}/langs/graphql.svg | 0 {assets => desktop/assets}/langs/haskell.svg | 0 {assets => desktop/assets}/langs/html.svg | 0 {assets => desktop/assets}/langs/java.svg | 0 {assets => desktop/assets}/langs/javascript.svg | 0 {assets => desktop/assets}/langs/json.svg | 0 {assets => desktop/assets}/langs/julia.svg | 0 {assets => desktop/assets}/langs/kotlin.svg | 0 {assets => desktop/assets}/langs/lua.svg | 0 {assets => desktop/assets}/langs/markdown.svg | 0 {assets => desktop/assets}/langs/markdown_light.svg | 0 {assets => desktop/assets}/langs/php.svg | 0 {assets => desktop/assets}/langs/php_light.svg | 0 {assets => desktop/assets}/langs/powershell.svg | 0 {assets => desktop/assets}/langs/python.svg | 0 {assets => desktop/assets}/langs/r.svg | 0 {assets => desktop/assets}/langs/r_light.svg | 0 {assets => desktop/assets}/langs/react.svg | 0 {assets => desktop/assets}/langs/react_light.svg | 0 {assets => desktop/assets}/langs/ruby.svg | 0 {assets => desktop/assets}/langs/rust.svg | 0 {assets => desktop/assets}/langs/rust_light.svg | 0 {assets => desktop/assets}/langs/sass.svg | 0 {assets => desktop/assets}/langs/scala.svg | 0 {assets => desktop/assets}/langs/solidity.svg | 0 {assets => desktop/assets}/langs/sql.svg | 0 {assets => desktop/assets}/langs/sql_light.svg | 0 {assets => desktop/assets}/langs/svelte.svg | 0 {assets => desktop/assets}/langs/swift.svg | 0 {assets => desktop/assets}/langs/terraform.svg | 0 {assets => desktop/assets}/langs/typescript.svg | 0 {assets => desktop/assets}/langs/vim.svg | 0 {assets => desktop/assets}/langs/vue.svg | 0 {assets => desktop/assets}/langs/zig.svg | 0 desktop/src/main/main.ts | 6 ++++++ 62 files changed, 7 insertions(+), 2 deletions(-) rename {assets => desktop/assets}/brand/kosmos-icon.svg (100%) rename {assets => desktop/assets}/brand/kosmos-logo.svg (100%) rename {assets => desktop/assets}/brand/kosmos-text.svg (100%) rename {assets => desktop/assets}/icon/icon-128.png (100%) rename {assets => desktop/assets}/icon/icon-16.png (100%) rename {assets => desktop/assets}/icon/icon-256.png (100%) rename {assets => desktop/assets}/icon/icon-32.png (100%) rename {assets => desktop/assets}/icon/icon-48.png (100%) rename {assets => desktop/assets}/icon/icon-512.png (100%) rename {assets => desktop/assets}/icon/icon-64.png (100%) rename {assets => desktop/assets}/icon/icon.svg (100%) rename {assets => desktop/assets}/langs/astro.svg (100%) rename {assets => desktop/assets}/langs/astro_light.svg (100%) rename {assets => desktop/assets}/langs/bash.svg (100%) rename {assets => desktop/assets}/langs/bash_light.svg (100%) rename {assets => desktop/assets}/langs/bun.svg (100%) rename {assets => desktop/assets}/langs/c.svg (100%) rename {assets => desktop/assets}/langs/cpp.svg (100%) rename {assets => desktop/assets}/langs/csharp.svg (100%) rename {assets => desktop/assets}/langs/css.svg (100%) rename {assets => desktop/assets}/langs/dart.svg (100%) rename {assets => desktop/assets}/langs/docker.svg (100%) rename {assets => desktop/assets}/langs/dotenv.svg (100%) rename {assets => desktop/assets}/langs/git.svg (100%) rename {assets => desktop/assets}/langs/go.svg (100%) rename {assets => desktop/assets}/langs/go_light.svg (100%) rename {assets => desktop/assets}/langs/graphql.svg (100%) rename {assets => desktop/assets}/langs/haskell.svg (100%) rename {assets => desktop/assets}/langs/html.svg (100%) rename {assets => desktop/assets}/langs/java.svg (100%) rename {assets => desktop/assets}/langs/javascript.svg (100%) rename {assets => desktop/assets}/langs/json.svg (100%) rename {assets => desktop/assets}/langs/julia.svg (100%) rename {assets => desktop/assets}/langs/kotlin.svg (100%) rename {assets => desktop/assets}/langs/lua.svg (100%) rename {assets => desktop/assets}/langs/markdown.svg (100%) rename {assets => desktop/assets}/langs/markdown_light.svg (100%) rename {assets => desktop/assets}/langs/php.svg (100%) rename {assets => desktop/assets}/langs/php_light.svg (100%) rename {assets => desktop/assets}/langs/powershell.svg (100%) rename {assets => desktop/assets}/langs/python.svg (100%) rename {assets => desktop/assets}/langs/r.svg (100%) rename {assets => desktop/assets}/langs/r_light.svg (100%) rename {assets => desktop/assets}/langs/react.svg (100%) rename {assets => desktop/assets}/langs/react_light.svg (100%) rename {assets => desktop/assets}/langs/ruby.svg (100%) rename {assets => desktop/assets}/langs/rust.svg (100%) rename {assets => desktop/assets}/langs/rust_light.svg (100%) rename {assets => desktop/assets}/langs/sass.svg (100%) rename {assets => desktop/assets}/langs/scala.svg (100%) rename {assets => desktop/assets}/langs/solidity.svg (100%) rename {assets => desktop/assets}/langs/sql.svg (100%) rename {assets => desktop/assets}/langs/sql_light.svg (100%) rename {assets => desktop/assets}/langs/svelte.svg (100%) rename {assets => desktop/assets}/langs/swift.svg (100%) rename {assets => desktop/assets}/langs/terraform.svg (100%) rename {assets => desktop/assets}/langs/typescript.svg (100%) rename {assets => desktop/assets}/langs/vim.svg (100%) rename {assets => desktop/assets}/langs/vue.svg (100%) rename {assets => desktop/assets}/langs/zig.svg (100%) diff --git a/README.md b/README.md index 470ae873..6481e2b7 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ Kosmos only runs on Linux. Windows and macOS are not supported, and support for - `core/` contains the Rust logic layer. - `server/` contains the Rust backend process that imports `core`. -- `desktop/` contains the Bun/Electron frontend. -- `assets/` contains shared branding and icon assets. +- `desktop/` contains the Bun/Electron frontend and its assets. ## Development diff --git a/assets/brand/kosmos-icon.svg b/desktop/assets/brand/kosmos-icon.svg similarity index 100% rename from assets/brand/kosmos-icon.svg rename to desktop/assets/brand/kosmos-icon.svg diff --git a/assets/brand/kosmos-logo.svg b/desktop/assets/brand/kosmos-logo.svg similarity index 100% rename from assets/brand/kosmos-logo.svg rename to desktop/assets/brand/kosmos-logo.svg diff --git a/assets/brand/kosmos-text.svg b/desktop/assets/brand/kosmos-text.svg similarity index 100% rename from assets/brand/kosmos-text.svg rename to desktop/assets/brand/kosmos-text.svg diff --git a/assets/icon/icon-128.png b/desktop/assets/icon/icon-128.png similarity index 100% rename from assets/icon/icon-128.png rename to desktop/assets/icon/icon-128.png diff --git a/assets/icon/icon-16.png b/desktop/assets/icon/icon-16.png similarity index 100% rename from assets/icon/icon-16.png rename to desktop/assets/icon/icon-16.png diff --git a/assets/icon/icon-256.png b/desktop/assets/icon/icon-256.png similarity index 100% rename from assets/icon/icon-256.png rename to desktop/assets/icon/icon-256.png diff --git a/assets/icon/icon-32.png b/desktop/assets/icon/icon-32.png similarity index 100% rename from assets/icon/icon-32.png rename to desktop/assets/icon/icon-32.png diff --git a/assets/icon/icon-48.png b/desktop/assets/icon/icon-48.png similarity index 100% rename from assets/icon/icon-48.png rename to desktop/assets/icon/icon-48.png diff --git a/assets/icon/icon-512.png b/desktop/assets/icon/icon-512.png similarity index 100% rename from assets/icon/icon-512.png rename to desktop/assets/icon/icon-512.png diff --git a/assets/icon/icon-64.png b/desktop/assets/icon/icon-64.png similarity index 100% rename from assets/icon/icon-64.png rename to desktop/assets/icon/icon-64.png diff --git a/assets/icon/icon.svg b/desktop/assets/icon/icon.svg similarity index 100% rename from assets/icon/icon.svg rename to desktop/assets/icon/icon.svg diff --git a/assets/langs/astro.svg b/desktop/assets/langs/astro.svg similarity index 100% rename from assets/langs/astro.svg rename to desktop/assets/langs/astro.svg diff --git a/assets/langs/astro_light.svg b/desktop/assets/langs/astro_light.svg similarity index 100% rename from assets/langs/astro_light.svg rename to desktop/assets/langs/astro_light.svg diff --git a/assets/langs/bash.svg b/desktop/assets/langs/bash.svg similarity index 100% rename from assets/langs/bash.svg rename to desktop/assets/langs/bash.svg diff --git a/assets/langs/bash_light.svg b/desktop/assets/langs/bash_light.svg similarity index 100% rename from assets/langs/bash_light.svg rename to desktop/assets/langs/bash_light.svg diff --git a/assets/langs/bun.svg b/desktop/assets/langs/bun.svg similarity index 100% rename from assets/langs/bun.svg rename to desktop/assets/langs/bun.svg diff --git a/assets/langs/c.svg b/desktop/assets/langs/c.svg similarity index 100% rename from assets/langs/c.svg rename to desktop/assets/langs/c.svg diff --git a/assets/langs/cpp.svg b/desktop/assets/langs/cpp.svg similarity index 100% rename from assets/langs/cpp.svg rename to desktop/assets/langs/cpp.svg diff --git a/assets/langs/csharp.svg b/desktop/assets/langs/csharp.svg similarity index 100% rename from assets/langs/csharp.svg rename to desktop/assets/langs/csharp.svg diff --git a/assets/langs/css.svg b/desktop/assets/langs/css.svg similarity index 100% rename from assets/langs/css.svg rename to desktop/assets/langs/css.svg diff --git a/assets/langs/dart.svg b/desktop/assets/langs/dart.svg similarity index 100% rename from assets/langs/dart.svg rename to desktop/assets/langs/dart.svg diff --git a/assets/langs/docker.svg b/desktop/assets/langs/docker.svg similarity index 100% rename from assets/langs/docker.svg rename to desktop/assets/langs/docker.svg diff --git a/assets/langs/dotenv.svg b/desktop/assets/langs/dotenv.svg similarity index 100% rename from assets/langs/dotenv.svg rename to desktop/assets/langs/dotenv.svg diff --git a/assets/langs/git.svg b/desktop/assets/langs/git.svg similarity index 100% rename from assets/langs/git.svg rename to desktop/assets/langs/git.svg diff --git a/assets/langs/go.svg b/desktop/assets/langs/go.svg similarity index 100% rename from assets/langs/go.svg rename to desktop/assets/langs/go.svg diff --git a/assets/langs/go_light.svg b/desktop/assets/langs/go_light.svg similarity index 100% rename from assets/langs/go_light.svg rename to desktop/assets/langs/go_light.svg diff --git a/assets/langs/graphql.svg b/desktop/assets/langs/graphql.svg similarity index 100% rename from assets/langs/graphql.svg rename to desktop/assets/langs/graphql.svg diff --git a/assets/langs/haskell.svg b/desktop/assets/langs/haskell.svg similarity index 100% rename from assets/langs/haskell.svg rename to desktop/assets/langs/haskell.svg diff --git a/assets/langs/html.svg b/desktop/assets/langs/html.svg similarity index 100% rename from assets/langs/html.svg rename to desktop/assets/langs/html.svg diff --git a/assets/langs/java.svg b/desktop/assets/langs/java.svg similarity index 100% rename from assets/langs/java.svg rename to desktop/assets/langs/java.svg diff --git a/assets/langs/javascript.svg b/desktop/assets/langs/javascript.svg similarity index 100% rename from assets/langs/javascript.svg rename to desktop/assets/langs/javascript.svg diff --git a/assets/langs/json.svg b/desktop/assets/langs/json.svg similarity index 100% rename from assets/langs/json.svg rename to desktop/assets/langs/json.svg diff --git a/assets/langs/julia.svg b/desktop/assets/langs/julia.svg similarity index 100% rename from assets/langs/julia.svg rename to desktop/assets/langs/julia.svg diff --git a/assets/langs/kotlin.svg b/desktop/assets/langs/kotlin.svg similarity index 100% rename from assets/langs/kotlin.svg rename to desktop/assets/langs/kotlin.svg diff --git a/assets/langs/lua.svg b/desktop/assets/langs/lua.svg similarity index 100% rename from assets/langs/lua.svg rename to desktop/assets/langs/lua.svg diff --git a/assets/langs/markdown.svg b/desktop/assets/langs/markdown.svg similarity index 100% rename from assets/langs/markdown.svg rename to desktop/assets/langs/markdown.svg diff --git a/assets/langs/markdown_light.svg b/desktop/assets/langs/markdown_light.svg similarity index 100% rename from assets/langs/markdown_light.svg rename to desktop/assets/langs/markdown_light.svg diff --git a/assets/langs/php.svg b/desktop/assets/langs/php.svg similarity index 100% rename from assets/langs/php.svg rename to desktop/assets/langs/php.svg diff --git a/assets/langs/php_light.svg b/desktop/assets/langs/php_light.svg similarity index 100% rename from assets/langs/php_light.svg rename to desktop/assets/langs/php_light.svg diff --git a/assets/langs/powershell.svg b/desktop/assets/langs/powershell.svg similarity index 100% rename from assets/langs/powershell.svg rename to desktop/assets/langs/powershell.svg diff --git a/assets/langs/python.svg b/desktop/assets/langs/python.svg similarity index 100% rename from assets/langs/python.svg rename to desktop/assets/langs/python.svg diff --git a/assets/langs/r.svg b/desktop/assets/langs/r.svg similarity index 100% rename from assets/langs/r.svg rename to desktop/assets/langs/r.svg diff --git a/assets/langs/r_light.svg b/desktop/assets/langs/r_light.svg similarity index 100% rename from assets/langs/r_light.svg rename to desktop/assets/langs/r_light.svg diff --git a/assets/langs/react.svg b/desktop/assets/langs/react.svg similarity index 100% rename from assets/langs/react.svg rename to desktop/assets/langs/react.svg diff --git a/assets/langs/react_light.svg b/desktop/assets/langs/react_light.svg similarity index 100% rename from assets/langs/react_light.svg rename to desktop/assets/langs/react_light.svg diff --git a/assets/langs/ruby.svg b/desktop/assets/langs/ruby.svg similarity index 100% rename from assets/langs/ruby.svg rename to desktop/assets/langs/ruby.svg diff --git a/assets/langs/rust.svg b/desktop/assets/langs/rust.svg similarity index 100% rename from assets/langs/rust.svg rename to desktop/assets/langs/rust.svg diff --git a/assets/langs/rust_light.svg b/desktop/assets/langs/rust_light.svg similarity index 100% rename from assets/langs/rust_light.svg rename to desktop/assets/langs/rust_light.svg diff --git a/assets/langs/sass.svg b/desktop/assets/langs/sass.svg similarity index 100% rename from assets/langs/sass.svg rename to desktop/assets/langs/sass.svg diff --git a/assets/langs/scala.svg b/desktop/assets/langs/scala.svg similarity index 100% rename from assets/langs/scala.svg rename to desktop/assets/langs/scala.svg diff --git a/assets/langs/solidity.svg b/desktop/assets/langs/solidity.svg similarity index 100% rename from assets/langs/solidity.svg rename to desktop/assets/langs/solidity.svg diff --git a/assets/langs/sql.svg b/desktop/assets/langs/sql.svg similarity index 100% rename from assets/langs/sql.svg rename to desktop/assets/langs/sql.svg diff --git a/assets/langs/sql_light.svg b/desktop/assets/langs/sql_light.svg similarity index 100% rename from assets/langs/sql_light.svg rename to desktop/assets/langs/sql_light.svg diff --git a/assets/langs/svelte.svg b/desktop/assets/langs/svelte.svg similarity index 100% rename from assets/langs/svelte.svg rename to desktop/assets/langs/svelte.svg diff --git a/assets/langs/swift.svg b/desktop/assets/langs/swift.svg similarity index 100% rename from assets/langs/swift.svg rename to desktop/assets/langs/swift.svg diff --git a/assets/langs/terraform.svg b/desktop/assets/langs/terraform.svg similarity index 100% rename from assets/langs/terraform.svg rename to desktop/assets/langs/terraform.svg diff --git a/assets/langs/typescript.svg b/desktop/assets/langs/typescript.svg similarity index 100% rename from assets/langs/typescript.svg rename to desktop/assets/langs/typescript.svg diff --git a/assets/langs/vim.svg b/desktop/assets/langs/vim.svg similarity index 100% rename from assets/langs/vim.svg rename to desktop/assets/langs/vim.svg diff --git a/assets/langs/vue.svg b/desktop/assets/langs/vue.svg similarity index 100% rename from assets/langs/vue.svg rename to desktop/assets/langs/vue.svg diff --git a/assets/langs/zig.svg b/desktop/assets/langs/zig.svg similarity index 100% rename from assets/langs/zig.svg rename to desktop/assets/langs/zig.svg diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 19039062..078212a4 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -58,6 +58,7 @@ function registerIpcHandlers(): void { function createMainWindow(): void { const runtimeDirectory = getRuntimeDirectory(); + const appIconPath = getAppIconPath(); const window = new BrowserWindow({ width: 1280, height: 800, @@ -67,6 +68,7 @@ function createMainWindow(): void { frame: false, autoHideMenuBar: true, backgroundColor: "#111217", + icon: appIconPath, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -80,6 +82,10 @@ function createMainWindow(): void { void window.loadFile(path.join(runtimeDirectory, "renderer", "index.html")); } +function getAppIconPath(): string { + return path.resolve(app.getAppPath(), "assets", "icon", "icon-512.png"); +} + function registerWindowShortcuts(window: BrowserWindow): void { window.webContents.on("before-input-event", (event, input) => { const key = input.key.toLowerCase(); From 19f8c7928bda6066c68faf172b23e03ed7594275 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 15:17:45 -0300 Subject: [PATCH 32/98] feat: add linux release packaging --- README.md | 5 +- aur/kosmos-bin/PKGBUILD | 42 +++ desktop/.gitignore | 1 + desktop/bun.lock | 481 ++++++++++++++++++++++++++++- desktop/package.json | 39 +++ desktop/src/main/main.ts | 23 +- desktop/src/main/server-process.ts | 124 ++++++++ scripts/bump-version.sh | 128 ++++++++ scripts/bundle-linux.sh | 15 + scripts/run.sh | 33 -- 10 files changed, 845 insertions(+), 46 deletions(-) create mode 100644 aur/kosmos-bin/PKGBUILD create mode 100644 desktop/src/main/server-process.ts create mode 100755 scripts/bump-version.sh create mode 100755 scripts/bundle-linux.sh diff --git a/README.md b/README.md index 6481e2b7..2e21ece9 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,11 @@ Kosmos only runs on Linux. Windows and macOS are not supported, and support for - Run the app with `./scripts/run.sh`. - Test the Rust workspace with `cargo test --workspace`. - Build the Electron desktop app with `bun install --cwd desktop` and `bun run --cwd desktop build`. +- Build a production Linux AppImage with `./scripts/bundle-linux.sh`. Artifacts are written to `desktop/release/`. +- The AppImage-based AUR package template lives in `aur/kosmos-bin/`. +- Bump release metadata with `./scripts/bump-version.sh patch|minor|major|x.y.z`. -The server and Electron main process communicate over a Unix socket. The renderer communicates with Electron main through Electron IPC only. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. +The Electron main process launches the Rust server as a sidecar process. They communicate over a Unix socket, while the renderer communicates with Electron main through Electron IPC only. By default, both use `$XDG_RUNTIME_DIR/kosmos/server.sock`; set `KOSMOS_SOCKET` to override it. ## License diff --git a/aur/kosmos-bin/PKGBUILD b/aur/kosmos-bin/PKGBUILD new file mode 100644 index 00000000..7ef63777 --- /dev/null +++ b/aur/kosmos-bin/PKGBUILD @@ -0,0 +1,42 @@ +# Maintainer: Martin Etchebarne +pkgname=kosmos-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="A code editor where every view is a tab you can place anywhere." +arch=('x86_64') +url="https://github.com/etchebarne/kosmos" +license=('MIT') +depends=('fuse2' 'hicolor-icon-theme') +provides=('kosmos') +conflicts=('kosmos') +options=('!strip') +source=( + "${pkgname}-${pkgver}.AppImage::${url}/releases/download/v${pkgver}/Kosmos-${pkgver}-x86_64.AppImage" + "kosmos-512.png::${url}/raw/v${pkgver}/desktop/assets/icon/icon-512.png" + "LICENSE::${url}/raw/v${pkgver}/LICENSE" +) +noextract=("${pkgname}-${pkgver}.AppImage") +sha256sums=('SKIP' 'SKIP' 'SKIP') + +package() { + install -Dm755 "${srcdir}/${pkgname}-${pkgver}.AppImage" "${pkgdir}/opt/kosmos/Kosmos.AppImage" + install -Dm644 "${srcdir}/kosmos-512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/kosmos.png" + install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + + install -Dm755 /dev/stdin "${pkgdir}/usr/bin/kosmos" <<'SH' +#!/usr/bin/env sh +exec /opt/kosmos/Kosmos.AppImage "$@" +SH + + install -Dm644 /dev/stdin "${pkgdir}/usr/share/applications/kosmos.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=Kosmos +Comment=A code editor where every view is a tab you can place anywhere. +Exec=kosmos +Icon=kosmos +Terminal=false +Categories=Development;IDE;TextEditor; +StartupWMClass=kosmos +DESKTOP +} diff --git a/desktop/.gitignore b/desktop/.gitignore index a14702c4..4043d14e 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -4,6 +4,7 @@ node_modules # output out dist +release *.tgz # code coverage diff --git a/desktop/bun.lock b/desktop/bun.lock index d91d4205..2dc9b03d 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -25,6 +25,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "electron": "^43.0.0", + "electron-builder": "^26.15.3", "tailwindcss": "^4.3.2", "typescript": "^5.9.3", "vite": "^8.1.3", @@ -100,8 +101,22 @@ "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], + "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.1.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-GFky+1JeO8fpSqtZCh+2wFRN/MVbAhm7tXI2M7BA1Os79OR8LEoxRtAbRPyxvmKWhEMF/p10rNJkkgP1klI13g=="], + + "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -120,6 +135,8 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -130,10 +147,16 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -142,6 +165,14 @@ "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], "@pierre/trees": ["@pierre/trees@1.0.0-beta.5", "", { "dependencies": { "@pierre/theming": "0.0.2", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA=="], @@ -180,8 +211,12 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], @@ -218,18 +253,38 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -238,30 +293,64 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], + + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -270,7 +359,13 @@ "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -278,12 +373,26 @@ "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -296,10 +405,14 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -310,6 +423,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -318,36 +433,68 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + + "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], - "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + "electron": ["electron@43.0.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ=="], + "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="], + + "electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.384", "", {}, "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -356,10 +503,16 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -370,6 +523,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -386,17 +541,23 @@ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -406,6 +567,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -416,20 +579,44 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -438,6 +625,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -450,6 +639,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], @@ -474,8 +665,14 @@ "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -486,18 +683,26 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -526,14 +731,20 @@ "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -546,6 +757,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -554,24 +767,46 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-abi": ["node-abi@4.33.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA=="], + + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -584,7 +819,9 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -602,10 +839,14 @@ "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -614,10 +855,16 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], @@ -626,16 +873,32 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -646,18 +909,36 @@ "react-resizable-panels": ["react-resizable-panels@4.12.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-t/Gp57qSCxGQ52ckhz+8lM7dnuymeU95TEzl2U203qEbGkSLHrtm7US2/ANzq/zOlja3CwPTAfCDuh1unv9mfw=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -666,14 +947,24 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -694,17 +985,27 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], @@ -716,6 +1017,8 @@ "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "systeminformation": ["systeminformation@5.31.11", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -724,14 +1027,28 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], + + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + + "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + + "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -740,6 +1057,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -754,10 +1073,14 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], @@ -766,13 +1089,27 @@ "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], @@ -786,18 +1123,42 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "@dotenvx/dotenvx/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "@dotenvx/dotenvx/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/universal/fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -810,8 +1171,20 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -822,30 +1195,74 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "electron/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "shadcn/fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -860,10 +1277,56 @@ "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/desktop/package.json b/desktop/package.json index cc36ce37..677e4240 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,5 +1,9 @@ { "name": "kosmos-desktop", + "version": "0.1.0", + "description": "A code editor where every view is a tab you can place anywhere.", + "author": "etchebarne", + "desktopName": "kosmos.desktop", "main": "dist/main.cjs", "type": "module", "private": true, @@ -10,6 +14,7 @@ "build:renderer": "vite build", "clean": "rm -rf dist", "dev": "bun run build && electron .", + "package:linux": "electron-builder --linux AppImage", "start": "electron .", "typecheck": "tsc --noEmit" }, @@ -21,6 +26,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "electron": "^43.0.0", + "electron-builder": "^26.15.3", "tailwindcss": "^4.3.2", "typescript": "^5.9.3", "vite": "^8.1.3" @@ -37,5 +43,38 @@ "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.14" + }, + "build": { + "appId": "com.etchebarne.kosmos", + "productName": "Kosmos", + "artifactName": "Kosmos-${version}-${arch}.${ext}", + "asar": true, + "icon": "assets/icon/icon-512.png", + "npmRebuild": false, + "directories": { + "output": "release" + }, + "files": [ + "dist/**/*", + "package.json" + ], + "extraResources": [ + { + "from": "assets", + "to": "assets" + }, + { + "from": "../target/release/kosmos-server", + "to": "bin/kosmos-server" + } + ], + "linux": { + "target": [ + "AppImage" + ], + "category": "Development", + "executableName": "kosmos", + "syncDesktopName": true + } } } diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 078212a4..39a017c9 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -4,9 +4,11 @@ import path from "node:path"; import type { KosmosIpcDomain, KosmosIpcRequest } from "../shared/ipc"; import { KosmosServerClient } from "./server-client"; +import { KosmosServerProcess } from "./server-process"; const validDomains = new Set(["workspace", "pane", "tab", "fileTree"]); const serverClient = new KosmosServerClient(); +const serverProcess = new KosmosServerProcess(serverClient.socketPath); function registerIpcHandlers(): void { ipcMain.handle("kosmos:request", (_event, request: KosmosIpcRequest) => { @@ -83,7 +85,9 @@ function createMainWindow(): void { } function getAppIconPath(): string { - return path.resolve(app.getAppPath(), "assets", "icon", "icon-512.png"); + const assetsDirectory = app.isPackaged ? process.resourcesPath : app.getAppPath(); + + return path.resolve(assetsDirectory, "assets", "icon", "icon-512.png"); } function registerWindowShortcuts(window: BrowserWindow): void { @@ -133,8 +137,9 @@ function validateRequest(request: KosmosIpcRequest): void { } } -app.whenReady().then(() => { +async function startApp(): Promise { Menu.setApplicationMenu(null); + await serverProcess.start(); registerIpcHandlers(); createMainWindow(); @@ -143,11 +148,23 @@ app.whenReady().then(() => { createMainWindow(); } }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +app.whenReady().then(startApp).catch((error: unknown) => { + dialog.showErrorBox("Kosmos failed to start", errorMessage(error)); + app.quit(); }); -app.on("window-all-closed", () => { +app.on("before-quit", () => { serverClient.disconnect(); + serverProcess.stop(); +}); +app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } diff --git a/desktop/src/main/server-process.ts b/desktop/src/main/server-process.ts new file mode 100644 index 00000000..6218a0ee --- /dev/null +++ b/desktop/src/main/server-process.ts @@ -0,0 +1,124 @@ +import { app } from "electron"; +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; + +const SERVER_START_TIMEOUT_MS = 5_000; +const SERVER_READY_POLL_MS = 50; +const SOCKET_CONNECT_TIMEOUT_MS = 100; + +export class KosmosServerProcess { + private child: ChildProcess | undefined; + + constructor(private readonly socketPath: string) {} + + async start(): Promise { + if (await canConnectToSocket(this.socketPath)) { + return; + } + + const child = this.spawn(); + await waitForServerSocket(child, this.socketPath); + } + + stop(): void { + if (!this.child || this.child.killed) { + return; + } + + this.child.kill(); + this.child = undefined; + } + + private spawn(): ChildProcess { + const serverPath = getServerBinaryPath(); + if (!fs.existsSync(serverPath)) { + throw new Error(`Kosmos server binary was not found at ${serverPath}`); + } + + const child = spawn(serverPath, [], { + env: { + ...process.env, + KOSMOS_SOCKET: this.socketPath, + }, + stdio: "ignore", + }); + + this.child = child; + child.once("exit", () => { + if (this.child === child) { + this.child = undefined; + } + }); + + return child; + } +} + +function getServerBinaryPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, "bin", "kosmos-server"); + } + + return path.resolve(app.getAppPath(), "..", "target", "debug", "kosmos-server"); +} + +async function waitForServerSocket(child: ChildProcess, socketPath: string): Promise { + const deadline = Date.now() + SERVER_START_TIMEOUT_MS; + let startError: Error | undefined; + const onError = (error: Error) => { + startError = error; + }; + + child.once("error", onError); + + try { + while (Date.now() < deadline) { + if (startError) { + throw startError; + } + + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error("Kosmos server exited before opening its IPC socket"); + } + + if (await canConnectToSocket(socketPath)) { + return; + } + + await sleep(SERVER_READY_POLL_MS); + } + } finally { + child.off("error", onError); + } + + throw new Error(`Kosmos server did not open its IPC socket at ${socketPath}`); +} + +function canConnectToSocket(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + + function finish(isReady: boolean): void { + if (settled) { + return; + } + + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(isReady); + } + + socket.setTimeout(SOCKET_CONNECT_TIMEOUT_MS); + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }); +} + +function sleep(durationMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, durationMs)); +} diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..0c6d4c69 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +DESKTOP_PACKAGE="desktop/package.json" +CORE_MANIFEST="core/Cargo.toml" +SERVER_MANIFEST="server/Cargo.toml" +AUR_PKGBUILD="aur/kosmos-bin/PKGBUILD" + +current_version() { + grep -m1 -E '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "$DESKTOP_PACKAGE" \ + | sed -E 's/.*"version": "([^"]+)".*/\1/' +} + +is_semver() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +next_version() { + local current="$1" + local bump="$2" + local major minor patch + + IFS=. read -r major minor patch <<< "$current" + + case "$bump" in + patch) + printf "%s.%s.%s\n" "$major" "$minor" "$((patch + 1))" + ;; + minor) + printf "%s.%s.0\n" "$major" "$((minor + 1))" + ;; + major) + printf "%s.0.0\n" "$((major + 1))" + ;; + *) + printf "%s\n" "$bump" + ;; + esac +} + +usage() { + local current="$1" + local major minor patch + + IFS=. read -r major minor patch <<< "$current" + + cat < + $0 patch|minor|major + +Examples: + $0 $major.$minor.$((patch + 1)) + $0 patch # $current -> $major.$minor.$((patch + 1)) + $0 minor # $current -> $major.$((minor + 1)).0 + $0 major # $current -> $((major + 1)).0.0 +USAGE +} + +require_current_version() { + local current="$1" + + require_contains "$DESKTOP_PACKAGE" "\"version\": \"$current\"" + require_contains "$CORE_MANIFEST" "version = \"$current\"" + require_contains "$SERVER_MANIFEST" "version = \"$current\"" + require_contains "$AUR_PKGBUILD" "pkgver=$current" +} + +require_contains() { + local file="$1" + local pattern="$2" + + if ! grep -q "$pattern" "$file"; then + echo "$file is not on version $CURRENT" >&2 + exit 1 + fi +} + +bump_version() { + local current="$1" + local next="$2" + + perl -0pi -e "s/\"version\": \"$current\"/\"version\": \"$next\"/g" "$DESKTOP_PACKAGE" + perl -0pi -e "s/version = \"$current\"/version = \"$next\"/g" "$CORE_MANIFEST" "$SERVER_MANIFEST" + perl -0pi -e "s/^pkgver=$current$/pkgver=$next/m; s/^pkgrel=.*/pkgrel=1/m" "$AUR_PKGBUILD" +} + +CURRENT="$(current_version)" + +if ! is_semver "$CURRENT"; then + echo "Could not read a valid current version from $DESKTOP_PACKAGE" >&2 + exit 1 +fi + +if [[ $# -eq 0 ]]; then + usage "$CURRENT" + exit 1 +fi + +NEXT="$(next_version "$CURRENT" "$1")" + +if ! is_semver "$NEXT"; then + echo "Version must be patch, minor, major, or x.y.z" >&2 + exit 1 +fi + +if [[ "$CURRENT" == "$NEXT" ]]; then + echo "Version is already $CURRENT" >&2 + exit 1 +fi + +require_current_version "$CURRENT" + +echo "Bumping version: $CURRENT -> $NEXT" + +bump_version "$CURRENT" "$NEXT" +cargo check --workspace >/dev/null + +echo "Updated:" +echo " $DESKTOP_PACKAGE" +echo " $CORE_MANIFEST" +echo " $SERVER_MANIFEST" +echo " $AUR_PKGBUILD" +echo " Cargo.lock" diff --git a/scripts/bundle-linux.sh b/scripts/bundle-linux.sh new file mode 100755 index 00000000..4544952c --- /dev/null +++ b/scripts/bundle-linux.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +cargo build --release --package kosmos-server + +if [[ ! -d "$DESKTOP_DIR/node_modules" ]] || [[ ! -x "$DESKTOP_DIR/node_modules/.bin/electron-builder" ]]; then + bun install --cwd "$DESKTOP_DIR" +fi + +bun run --cwd "$DESKTOP_DIR" typecheck +bun run --cwd "$DESKTOP_DIR" build +bun run --cwd "$DESKTOP_DIR" package:linux diff --git a/scripts/run.sh b/scripts/run.sh index 4c2c91fd..bd895936 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,47 +2,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SERVER_BIN="$ROOT_DIR/target/debug/kosmos-server" DESKTOP_DIR="$ROOT_DIR/desktop" RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}" export KOSMOS_SOCKET="${KOSMOS_SOCKET:-$RUNTIME_DIR/kosmos/server.sock}" -server_pid="" - -cleanup() { - if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then - kill "$server_pid" - wait "$server_pid" 2>/dev/null || true - fi -} - -trap cleanup EXIT INT TERM - cargo build --package kosmos-server if [[ ! -d "$DESKTOP_DIR/node_modules" ]]; then bun install --cwd "$DESKTOP_DIR" fi bun run --cwd "$DESKTOP_DIR" build -"$SERVER_BIN" & -server_pid="$!" - -for _ in {1..50}; do - if [[ -S "$KOSMOS_SOCKET" ]]; then - break - fi - - if ! kill -0 "$server_pid" 2>/dev/null; then - echo "kosmos server failed to start" >&2 - exit 1 - fi - - sleep 0.1 -done - -if [[ ! -S "$KOSMOS_SOCKET" ]]; then - echo "kosmos server socket was not created at $KOSMOS_SOCKET" >&2 - exit 1 -fi - bun run --cwd "$DESKTOP_DIR" start -- "$@" From 59dfad70fbcb299b5cf439fa8502adba5b41bc03 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 15:45:02 -0300 Subject: [PATCH 33/98] style: align tab strip padding --- desktop/src/renderer/components/internal/workspace-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index 6b0f7e29..a83022f6 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -217,7 +217,7 @@ function PaneLeaf({ className="h-full min-h-0 gap-0" onValueChange={(value) => activateTabFromValue(value, pane, activateTab)} > -
+
Date: Mon, 6 Jul 2026 16:00:09 -0300 Subject: [PATCH 34/98] fix: theme file tree accent --- desktop/src/renderer/components/tabs/file-tree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/renderer/components/tabs/file-tree.tsx b/desktop/src/renderer/components/tabs/file-tree.tsx index f4719ac2..36bcbdb3 100644 --- a/desktop/src/renderer/components/tabs/file-tree.tsx +++ b/desktop/src/renderer/components/tabs/file-tree.tsx @@ -360,7 +360,7 @@ function LoadedFileTree({
( createPortal( From 9c853d41b746fbf8d2fb967f3d4edbe8ef578e14 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 16:07:12 -0300 Subject: [PATCH 35/98] chore: remove settings tab --- core/src/persistence/mod.rs | 4 +--- core/src/tree/tabs.rs | 2 -- desktop/src/renderer/components/tabs/registry.tsx | 3 --- desktop/src/shared/ipc/tab.ts | 2 +- server/src/ipc/messages/tab.rs | 3 --- 5 files changed, 2 insertions(+), 12 deletions(-) diff --git a/core/src/persistence/mod.rs b/core/src/persistence/mod.rs index 9fd1a010..c057f6fa 100644 --- a/core/src/persistence/mod.rs +++ b/core/src/persistence/mod.rs @@ -170,7 +170,7 @@ fn migrate(connection: &Connection) -> Result<()> { UPDATE tabs SET kind = 'blank' - WHERE kind NOT IN ('blank', 'file_tree', 'editor', 'git', 'search', 'terminal', 'settings'); + WHERE kind NOT IN ('blank', 'file_tree', 'editor', 'git', 'search', 'terminal'); ", )?; @@ -668,7 +668,6 @@ fn tab_kind_name(kind: &TabKind) -> &'static str { TabKind::Git => "git", TabKind::Search => "search", TabKind::Terminal => "terminal", - TabKind::Settings => "settings", } } @@ -680,7 +679,6 @@ fn parse_tab_kind(kind: &str) -> Result { "git" => Ok(TabKind::Git), "search" => Ok(TabKind::Search), "terminal" => Ok(TabKind::Terminal), - "settings" => Ok(TabKind::Settings), _ => Err(invalid_state(format!("unknown tab kind {kind:?}"))), } } diff --git a/core/src/tree/tabs.rs b/core/src/tree/tabs.rs index a3216b5d..97569787 100644 --- a/core/src/tree/tabs.rs +++ b/core/src/tree/tabs.rs @@ -57,7 +57,6 @@ pub enum TabKind { Git, Search, Terminal, - Settings, } impl TabKind { @@ -69,7 +68,6 @@ impl TabKind { Self::Git => "Git", Self::Search => "Search", Self::Terminal => "Terminal", - Self::Settings => "Settings", } } } diff --git a/desktop/src/renderer/components/tabs/registry.tsx b/desktop/src/renderer/components/tabs/registry.tsx index 00eb3d6d..22015684 100644 --- a/desktop/src/renderer/components/tabs/registry.tsx +++ b/desktop/src/renderer/components/tabs/registry.tsx @@ -5,7 +5,6 @@ import { FolderTree, GitBranch, Search, - Settings, Terminal, type LucideIcon, } from "lucide-react"; @@ -38,7 +37,6 @@ const TAB_KIND_ORDER: TabKind[] = [ "git", "search", "terminal", - "settings", ]; const TAB_DEFINITIONS: Record = { @@ -66,7 +64,6 @@ const TAB_DEFINITIONS: Record = { git: placeholderTabDefinition("Git", GitBranch, true), search: placeholderTabDefinition("Search", Search, true), terminal: placeholderTabDefinition("Terminal", Terminal, true), - settings: placeholderTabDefinition("Settings", Settings, true), }; const BLANK_TAB_OPTIONS: BlankTabOption[] = TAB_KIND_ORDER.filter( diff --git a/desktop/src/shared/ipc/tab.ts b/desktop/src/shared/ipc/tab.ts index 8a498298..e2c45be8 100644 --- a/desktop/src/shared/ipc/tab.ts +++ b/desktop/src/shared/ipc/tab.ts @@ -1,7 +1,7 @@ import type { PaneId, TabId, WorkspaceId } from "./ids"; import type { SplitAxis } from "./pane"; -export type TabKind = "blank" | "fileTree" | "editor" | "git" | "search" | "terminal" | "settings"; +export type TabKind = "blank" | "fileTree" | "editor" | "git" | "search" | "terminal"; export type OpenTabParams = { workspaceId?: WorkspaceId | null; diff --git a/server/src/ipc/messages/tab.rs b/server/src/ipc/messages/tab.rs index 62d0b081..9bc84625 100644 --- a/server/src/ipc/messages/tab.rs +++ b/server/src/ipc/messages/tab.rs @@ -76,7 +76,6 @@ pub(crate) enum TabKindPayload { Git, Search, Terminal, - Settings, } impl From for TabKind { @@ -88,7 +87,6 @@ impl From for TabKind { TabKindPayload::Git => Self::Git, TabKindPayload::Search => Self::Search, TabKindPayload::Terminal => Self::Terminal, - TabKindPayload::Settings => Self::Settings, } } } @@ -102,7 +100,6 @@ impl From<&TabKind> for TabKindPayload { TabKind::Git => Self::Git, TabKind::Search => Self::Search, TabKind::Terminal => Self::Terminal, - TabKind::Settings => Self::Settings, } } } From 0c18482b5821996256d064fa0bbf243d9daeb8a1 Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 18:43:40 -0300 Subject: [PATCH 36/98] fix: move tabs between pane tab groups --- core/src/state.rs | 82 ++++++++++++++ core/src/tree/workspaces.rs | 57 ++++++++++ .../components/internal/workspace-view.tsx | 103 +++++++++++++++++- desktop/src/renderer/ipc/tab.ts | 5 + .../src/renderer/stores/workspace-store.ts | 12 ++ desktop/src/shared/ipc/tab.ts | 8 ++ server/src/ipc/messages/tab.rs | 10 ++ server/src/ipc/router/tab.rs | 24 +++- 8 files changed, 298 insertions(+), 3 deletions(-) diff --git a/core/src/state.rs b/core/src/state.rs index 7630c498..16427d2a 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -439,6 +439,24 @@ impl State { workspace.reorder_tab_in_pane(pane_id, tab_id, target_index) } + pub fn move_tab( + &mut self, + workspace_id: Option, + pane_id: PaneId, + target_pane_id: PaneId, + tab_id: TabId, + target_index: usize, + ) -> bool { + let Some(workspace_id) = self.resolve_workspace_id(workspace_id) else { + return false; + }; + let Some(workspace) = self.workspace_mut(workspace_id) else { + return false; + }; + + workspace.move_tab_to_pane(pane_id, target_pane_id, tab_id, target_index) + } + pub fn resize_split( &mut self, workspace_id: Option, @@ -836,6 +854,70 @@ mod tests { assert_eq!(workspace.active_pane_id(), PaneId::new(1)); } + #[test] + fn moving_tab_to_another_pane_adds_it_to_target_pane() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + state.open_tab(None, None, "Search", TabKind::Search); + assert!(state.split_pane(None, None, SplitAxis::Horizontal, false)); + + assert!(state.move_tab(None, PaneId::new(1), PaneId::new(2), TabId::new(2), 1,)); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let source_pane = workspace + .root() + .find_pane(PaneId::new(1)) + .expect("source pane should remain"); + let target_pane = workspace + .root() + .find_pane(PaneId::new(2)) + .expect("target pane should exist"); + + assert_eq!(source_pane.tabs().len(), 1); + assert_eq!( + target_pane.tabs().iter().map(Tab::id).collect::>(), + vec![TabId::new(3), TabId::new(2)] + ); + assert_eq!(target_pane.active_tab_id(), TabId::new(2)); + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + } + + #[test] + fn moving_last_tab_to_another_pane_removes_source_pane() { + let mut state = State::new(); + state.open_workspace("/workspaces/main"); + assert!(state.split_pane(None, None, SplitAxis::Horizontal, false)); + + assert!(state.move_tab( + None, + PaneId::new(1), + PaneId::new(2), + TabId::new(1), + usize::MAX, + )); + + let workspace = state + .workspaces() + .active_workspace() + .expect("workspace should be active"); + let target_pane = workspace + .root() + .find_pane(PaneId::new(2)) + .expect("target pane should exist"); + + assert_eq!(workspace.root().pane_count(), 1); + assert!(!workspace.root().contains_pane(PaneId::new(1))); + assert_eq!( + target_pane.tabs().iter().map(Tab::id).collect::>(), + vec![TabId::new(2), TabId::new(1)] + ); + assert_eq!(target_pane.active_tab_id(), TabId::new(1)); + assert_eq!(workspace.active_pane_id(), PaneId::new(2)); + } + #[test] fn resizing_split_updates_server_owned_ratio() { let mut state = State::new(); diff --git a/core/src/tree/workspaces.rs b/core/src/tree/workspaces.rs index 3366e810..870543a0 100644 --- a/core/src/tree/workspaces.rs +++ b/core/src/tree/workspaces.rs @@ -164,6 +164,44 @@ impl Workspace { reordered } + pub fn move_tab_to_pane( + &mut self, + pane_id: PaneId, + target_pane_id: PaneId, + tab_id: TabId, + target_index: usize, + ) -> bool { + if pane_id == target_pane_id { + return self.reorder_tab_in_pane(pane_id, tab_id, target_index); + } + + let Some(source_pane) = self.root.find_pane(pane_id) else { + return false; + }; + if !source_pane.contains_tab(tab_id) || !self.root.contains_pane(target_pane_id) { + return false; + } + + let remove_source_pane = source_pane.tabs().len() == 1; + let Some(tab) = self + .root + .find_pane_mut(pane_id) + .and_then(|pane| pane.remove_tab(tab_id)) + else { + return false; + }; + + if remove_source_pane && !self.remove_pane_after_tab_move(pane_id) { + return false; + } + + if self.insert_tab_in_pane(target_pane_id, target_index, tab) { + self.activate_tab(target_pane_id, tab_id) + } else { + false + } + } + pub fn activate_tab_in_active_pane(&mut self, tab_id: TabId) -> bool { self.active_pane_mut() .is_some_and(|pane| pane.activate_tab(tab_id)) @@ -300,6 +338,25 @@ impl Workspace { self.active_pane = fallback_pane_id; } } + + fn remove_pane_after_tab_move(&mut self, pane_id: PaneId) -> bool { + let (root, removed_pane) = self.root.clone().remove_pane(pane_id); + + debug_assert!(removed_pane.is_some()); + + let Some(root) = root else { + return false; + }; + + self.active_pane = if root.contains_pane(self.active_pane) { + self.active_pane + } else { + root.first_pane_id() + }; + self.root = root; + + true + } } fn remove_pane_from_root(root: &PaneNode, pane_id: PaneId) -> Option<(PaneNode, Pane)> { diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index a83022f6..696b3ef5 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -36,7 +36,13 @@ type DraggedTab = { tabId: TabId; }; +type TabDropTarget = { + index: number; + x: number; +}; + const TAB_DRAG_MIME = "application/x-kosmos-tab"; +const TAB_TRIGGER_SELECTOR = "[data-kosmos-tab-trigger]"; const MIN_PANE_SIZE_REM = 16; export function WorkspaceView() { @@ -157,8 +163,10 @@ function PaneLeaf({ isRoot: boolean; }) { const [dropEdge, setDropEdge] = useState(null); + const [tabDropTarget, setTabDropTarget] = useState(null); const activatePane = useWorkspaceStore((state) => state.activatePane); const activateTab = useWorkspaceStore((state) => state.activateTab); + const moveTab = useWorkspaceStore((state) => state.moveTab); const openTab = useWorkspaceStore((state) => state.openTab); const splitTab = useWorkspaceStore((state) => state.splitTab); @@ -175,12 +183,14 @@ function PaneLeaf({ } setDropEdge(null); + setTabDropTarget(null); }} onDragOver={(event) => { if (!hasDraggedTab(event)) { return; } + setTabDropTarget(null); const edge = edgeFromDragEvent(event, event.currentTarget); setDropEdge(edge); @@ -196,6 +206,7 @@ function PaneLeaf({ const draggedTab = readDraggedTab(event); const edge = edgeFromDragEvent(event, event.currentTarget); setDropEdge(null); + setTabDropTarget(null); if (!draggedTab || !edge) { return; @@ -217,7 +228,37 @@ function PaneLeaf({ className="h-full min-h-0 gap-0" onValueChange={(value) => activateTabFromValue(value, pane, activateTab)} > -
+
{ + if (!hasDraggedTab(event)) { + return; + } + + const target = tabDropTargetFromDragEvent(event, event.currentTarget); + setDropEdge(null); + setTabDropTarget(target); + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + const draggedTab = readDraggedTab(event); + if (!draggedTab) { + return; + } + + const target = tabDropTargetFromDragEvent(event, event.currentTarget); + setDropEdge(null); + setTabDropTarget(null); + event.preventDefault(); + event.stopPropagation(); + + if (!isTabDropNoop(pane, draggedTab, target.index)) { + moveTab(draggedTab.paneId, draggedTab.tabId, pane.id, target.index); + } + }} + > + +
{pane.tabs.map((tab) => ( @@ -275,6 +318,7 @@ function TabTrigger({ nativeButton={false} draggable render={
} + data-kosmos-tab-trigger="" className="group/tab max-w-52 flex-none cursor-default justify-start px-2 text-xs data-active:!bg-foreground/10 data-active:!text-foreground" onDragStart={(event) => writeDraggedTab(event, pane.id, tab.id, tab.title)} > @@ -338,6 +382,19 @@ function TabBody({ ); } +function TabDropIndicator({ target }: { target: TabDropTarget | null }) { + if (!target) { + return null; + } + + return ( +
+ ); +} + function DropIndicator({ edge }: { edge: DropEdge | null }) { if (!edge) { return null; @@ -427,6 +484,46 @@ function hasDraggedTab(event: DragEvent): boolean { return false; } +function tabDropTargetFromDragEvent( + event: DragEvent, + element: HTMLElement, +): TabDropTarget { + const containerRect = element.getBoundingClientRect(); + const tabElements = Array.from(element.querySelectorAll(TAB_TRIGGER_SELECTOR)); + + if (tabElements.length === 0) { + return { + index: 0, + x: clamp(event.clientX - containerRect.left, 0, containerRect.width), + }; + } + + for (let index = 0; index < tabElements.length; index += 1) { + const tabRect = tabElements[index]!.getBoundingClientRect(); + if (event.clientX < tabRect.left + tabRect.width / 2) { + return { + index, + x: clamp(tabRect.left - containerRect.left, 0, containerRect.width), + }; + } + } + + const lastTabRect = tabElements[tabElements.length - 1]!.getBoundingClientRect(); + return { + index: tabElements.length, + x: clamp(lastTabRect.right - containerRect.left, 0, containerRect.width), + }; +} + +function isTabDropNoop(pane: PaneSnapshot, draggedTab: DraggedTab, targetIndex: number): boolean { + if (draggedTab.paneId !== pane.id) { + return false; + } + + const currentIndex = pane.tabs.findIndex((tab) => tab.id === draggedTab.tabId); + return currentIndex === -1 || targetIndex === currentIndex || targetIndex === currentIndex + 1; +} + function edgeFromDragEvent(event: DragEvent, element: HTMLElement): DropEdge | null { const rect = element.getBoundingClientRect(); const threshold = Math.min(96, Math.max(40, Math.min(rect.width, rect.height) * 0.28)); @@ -462,6 +559,10 @@ function percentSize(value: number): string { return `${value}%`; } +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + function minimumNodeSize(node: PaneNodeSnapshot, axis: SplitAxis): string { return `${minimumNodeSizeRem(node, axis)}rem`; } diff --git a/desktop/src/renderer/ipc/tab.ts b/desktop/src/renderer/ipc/tab.ts index 39773a3f..87c9f5f3 100644 --- a/desktop/src/renderer/ipc/tab.ts +++ b/desktop/src/renderer/ipc/tab.ts @@ -1,6 +1,7 @@ import type { ActivateTabParams, CloseTabParams, + MoveTabParams, OpenTabParams, ReorderTabParams, SetTabKindParams, @@ -32,6 +33,10 @@ export function reorderTab(params: ReorderTabParams): Promise { + return requestServer(DOMAIN, "move", params); +} + export function splitTab(params: SplitTabParams): Promise { return requestServer(DOMAIN, "split", params); } diff --git a/desktop/src/renderer/stores/workspace-store.ts b/desktop/src/renderer/stores/workspace-store.ts index ea2b4917..86cdf68c 100644 --- a/desktop/src/renderer/stores/workspace-store.ts +++ b/desktop/src/renderer/stores/workspace-store.ts @@ -14,6 +14,7 @@ import { closeTab as closeTabIpc, closeWorkspace as closeWorkspaceIpc, listWorkspaces, + moveTab as moveTabIpc, openTab as openTabIpc, openWorkspace, resizeSplit as resizeSplitIpc, @@ -49,6 +50,7 @@ type WorkspaceStore = { closeTab(paneId: PaneId, tabId: TabId): void; closeWorkspace(workspaceId: WorkspaceId): Promise; initializeWorkspaces(): Promise; + moveTab(paneId: PaneId, tabId: TabId, targetPaneId: PaneId, targetIndex: number): void; openTab(paneId: PaneId): void; registerFileTreeExpansionFlusher(flusher: FileTreeExpansionFlusher): () => void; resizeSplit(splitId: SplitPaneId, ratio: number): void; @@ -206,6 +208,16 @@ export const useWorkspaceStore = create((set, get) => { } } }, + moveTab(paneId, tabId, targetPaneId, targetIndex) { + const activeWorkspace = activeWorkspaceFrom(get().snapshot); + if (!activeWorkspace) { + return; + } + + updateFromServer(() => + moveTabIpc({ workspaceId: activeWorkspace.id, paneId, tabId, targetPaneId, targetIndex }), + ); + }, openTab(paneId) { const activeWorkspace = activeWorkspaceFrom(get().snapshot); if (!activeWorkspace) { diff --git a/desktop/src/shared/ipc/tab.ts b/desktop/src/shared/ipc/tab.ts index e2c45be8..1d98948d 100644 --- a/desktop/src/shared/ipc/tab.ts +++ b/desktop/src/shared/ipc/tab.ts @@ -36,6 +36,14 @@ export type ReorderTabParams = { targetIndex: number; }; +export type MoveTabParams = { + workspaceId?: WorkspaceId | null; + paneId: PaneId; + targetPaneId: PaneId; + tabId: TabId; + targetIndex: number; +}; + export type SplitTabParams = { workspaceId?: WorkspaceId | null; paneId: PaneId; diff --git a/server/src/ipc/messages/tab.rs b/server/src/ipc/messages/tab.rs index 9bc84625..5bfa424c 100644 --- a/server/src/ipc/messages/tab.rs +++ b/server/src/ipc/messages/tab.rs @@ -46,6 +46,16 @@ pub(crate) struct ReorderTabParams { pub(crate) target_index: usize, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveTabParams { + pub(crate) workspace_id: Option, + pub(crate) pane_id: PaneIdParam, + pub(crate) target_pane_id: PaneIdParam, + pub(crate) tab_id: TabIdParam, + pub(crate) target_index: usize, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SplitTabParams { diff --git a/server/src/ipc/router/tab.rs b/server/src/ipc/router/tab.rs index d358c651..a80c479f 100644 --- a/server/src/ipc/router/tab.rs +++ b/server/src/ipc/router/tab.rs @@ -1,7 +1,7 @@ use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; use super::super::messages::tab::{ - ActivateTabParams, CloseTabParams, OpenTabParams, ReorderTabParams, SetTabKindParams, - SplitTabParams, + ActivateTabParams, CloseTabParams, MoveTabParams, OpenTabParams, ReorderTabParams, + SetTabKindParams, SplitTabParams, }; use super::{command_response, parse_params, unsupported_action}; @@ -12,6 +12,7 @@ pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> Serve "setKind" => set_tab_kind(state, request), "close" => close_tab(state, request), "reorder" => reorder_tab(state, request), + "move" => move_tab(state, request), "split" => split_tab(state, request), _ => unsupported_action(request), } @@ -107,6 +108,25 @@ fn reorder_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMess } } +fn move_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => command_response( + request.id, + state.move_tab( + params.workspace_id.map(Into::into), + params.pane_id.into(), + params.target_pane_id.into(), + params.tab_id.into(), + params.target_index, + ), + state, + "tab.move_failed", + "tab could not be moved", + ), + Err(response) => response, + } +} + fn split_tab(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match parse_params::(request) { Ok(params) => command_response( From 48f55ca43122b52157a89270018e1e12ac0d42ac Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 19:29:38 -0300 Subject: [PATCH 37/98] fix: lazily load large file tree directories --- core/src/file_tree.rs | 236 +++++++++++++++++- core/src/state.rs | 15 +- .../renderer/components/tabs/file-tree.tsx | 87 +++++++ desktop/src/renderer/ipc/file-tree.ts | 10 + desktop/src/shared/ipc/file-tree.ts | 12 + server/src/ipc/messages/file_tree.rs | 28 ++- server/src/ipc/router/file_tree.rs | 26 +- server/src/ipc/transport/connection.rs | 2 +- 8 files changed, 402 insertions(+), 14 deletions(-) diff --git a/core/src/file_tree.rs b/core/src/file_tree.rs index 60f91024..58c5e884 100644 --- a/core/src/file_tree.rs +++ b/core/src/file_tree.rs @@ -11,6 +11,31 @@ use crate::tree::{TabId, WorkspaceId}; pub type Result = std::result::Result; +const MAX_FILE_TREE_PATHS: usize = 50_000; +const IGNORED_DIRECTORY_NAMES: &[&str] = &[ + ".angular", + ".cache", + ".git", + ".hg", + ".mypy_cache", + ".next", + ".nuxt", + ".parcel-cache", + ".pytest_cache", + ".ruff_cache", + ".svn", + ".svelte-kit", + ".turbo", + ".venv", + "__pycache__", + "build", + "coverage", + "dist", + "node_modules", + "target", + "venv", +]; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FileTreeEntryKind { Directory, @@ -22,6 +47,13 @@ pub struct FileTree { root: PathBuf, paths: Vec, expanded_paths: Vec, + deferred_paths: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileTreeDirectory { + paths: Vec, + deferred_paths: Vec, } impl FileTree { @@ -38,7 +70,14 @@ impl FileTree { let mut paths = Vec::new(); let mut directory_paths = HashSet::new(); - collect_paths(&root, "", &mut paths, &mut directory_paths)?; + let mut deferred_paths = Vec::new(); + collect_paths( + &root, + "", + &mut paths, + &mut directory_paths, + &mut deferred_paths, + )?; let expanded_paths = normalize_expanded_paths(expanded_paths) .into_iter() .filter(|path| directory_paths.contains(path)) @@ -48,6 +87,32 @@ impl FileTree { root, paths, expanded_paths, + deferred_paths, + }) + } + + pub fn scan_children( + root: impl AsRef, + directory_path: &str, + ) -> Result { + let root = root.as_ref(); + ensure_directory(root)?; + let normalized_path = normalize_relative_path(directory_path, PathUsage::Directory)?; + let directory = resolve_directory(root, &normalized_path)?; + let relative_directory = path_to_str(&normalized_path); + let mut paths = Vec::new(); + let mut deferred_paths = Vec::new(); + + collect_child_paths( + &directory, + relative_directory, + &mut paths, + &mut deferred_paths, + )?; + + Ok(FileTreeDirectory { + paths, + deferred_paths, }) } @@ -63,6 +128,10 @@ impl FileTree { &self.expanded_paths } + pub fn deferred_paths(&self) -> &[String] { + &self.deferred_paths + } + pub fn create_entry( root: impl AsRef, parent_path: Option<&str>, @@ -194,6 +263,16 @@ impl FileTree { } } +impl FileTreeDirectory { + pub fn paths(&self) -> &[String] { + &self.paths + } + + pub fn deferred_paths(&self) -> &[String] { + &self.deferred_paths + } +} + fn delete_resolved_entry(path: PathBuf) -> Result<()> { let file_type = fs::symlink_metadata(&path) .map_err(|error| io_error(path.clone(), error))? @@ -252,6 +331,9 @@ pub enum FileTreeError { }, RootNotDirectory(PathBuf), TabNotFound, + TooManyEntries { + limit: usize, + }, UnsupportedEntry(PathBuf), WorkspaceNotFound, } @@ -286,6 +368,9 @@ impl fmt::Display for FileTreeError { write!(formatter, "{} is not a directory", path.display()) } Self::TabNotFound => formatter.write_str("file tree tab does not exist"), + Self::TooManyEntries { limit } => { + write!(formatter, "file tree contains more than {limit} entries") + } Self::UnsupportedEntry(path) => { write!(formatter, "unsupported file tree entry: {}", path.display()) } @@ -306,6 +391,7 @@ impl StdError for FileTreeError { | Self::InvalidPath(_) | Self::RootNotDirectory(_) | Self::TabNotFound + | Self::TooManyEntries { .. } | Self::UnsupportedEntry(_) | Self::WorkspaceNotFound => None, } @@ -649,23 +735,60 @@ fn collect_paths( relative_directory: &str, paths: &mut Vec, directory_paths: &mut HashSet, + deferred_paths: &mut Vec, +) -> Result<()> { + for entry in sorted_entries(directory, paths.len())? { + let relative_path = relative_path(relative_directory, &entry.name); + + if entry.is_directory { + let directory_path = format!("{relative_path}/"); + + push_path(paths, directory_path.clone())?; + directory_paths.insert(directory_path); + + if is_ignored_directory_name(&entry.name) { + deferred_paths.push(format!("{relative_path}/")); + continue; + } + + collect_paths( + &entry.path, + &relative_path, + paths, + directory_paths, + deferred_paths, + )?; + } else { + push_path(paths, relative_path)?; + } + } + + Ok(()) +} + +fn collect_child_paths( + directory: &Path, + relative_directory: &str, + paths: &mut Vec, + deferred_paths: &mut Vec, ) -> Result<()> { - for entry in sorted_entries(directory)? { + for entry in sorted_entries(directory, paths.len())? { let relative_path = relative_path(relative_directory, &entry.name); if entry.is_directory { - paths.push(format!("{relative_path}/")); - directory_paths.insert(format!("{relative_path}/")); - collect_paths(&entry.path, &relative_path, paths, directory_paths)?; + let directory_path = format!("{relative_path}/"); + + push_path(paths, directory_path.clone())?; + deferred_paths.push(directory_path); } else { - paths.push(relative_path); + push_path(paths, relative_path)?; } } Ok(()) } -fn sorted_entries(directory: &Path) -> Result> { +fn sorted_entries(directory: &Path, existing_path_count: usize) -> Result> { let entries = fs::read_dir(directory).map_err(|error| io_error(directory, error))?; let mut file_tree_entries = Vec::new(); @@ -673,11 +796,15 @@ fn sorted_entries(directory: &Path) -> Result> { let entry = entry.map_err(|error| io_error(directory, error))?; let path = entry.path(); let file_type = entry.file_type().map_err(|error| io_error(&path, error))?; + let is_directory = file_type.is_dir(); + let name = entry.file_name().to_string_lossy().into_owned(); + + ensure_path_capacity(existing_path_count.saturating_add(file_tree_entries.len()))?; file_tree_entries.push(FileTreeEntry { - name: entry.file_name().to_string_lossy().into_owned(), + name, path, - is_directory: file_type.is_dir(), + is_directory, }); } @@ -686,6 +813,28 @@ fn sorted_entries(directory: &Path) -> Result> { Ok(file_tree_entries) } +fn push_path(paths: &mut Vec, path: String) -> Result<()> { + ensure_path_capacity(paths.len())?; + + paths.push(path); + + Ok(()) +} + +fn ensure_path_capacity(current_path_count: usize) -> Result<()> { + if current_path_count < MAX_FILE_TREE_PATHS { + Ok(()) + } else { + Err(FileTreeError::TooManyEntries { + limit: MAX_FILE_TREE_PATHS, + }) + } +} + +fn is_ignored_directory_name(name: &str) -> bool { + IGNORED_DIRECTORY_NAMES.contains(&name) +} + fn compare_entries(left: &FileTreeEntry, right: &FileTreeEntry) -> Ordering { match (left.is_directory, right.is_directory) { (true, false) => Ordering::Less, @@ -807,6 +956,75 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn scan_keeps_generated_dependency_and_build_directories_collapsed() { + let root = test_root("ignored-directories"); + fs::create_dir_all(root.join(".git/objects")).expect("git metadata should be created"); + fs::create_dir_all(root.join("node_modules/pkg")).expect("dependencies should be created"); + fs::create_dir_all(root.join("src")).expect("source directory should be created"); + fs::create_dir_all(root.join("target/debug")).expect("build output should be created"); + fs::write(root.join("node_modules/pkg/index.js"), b"export {}") + .expect("dependency file should be written"); + fs::write(root.join("src/main.rs"), b"fn main() {}") + .expect("source file should be written"); + + let tree = FileTree::scan(&root).expect("file tree should scan"); + + assert_eq!( + tree.paths(), + &[".git/", "node_modules/", "src/", "src/main.rs", "target/"] + ); + assert_eq!( + tree.deferred_paths(), + &[".git/", "node_modules/", "target/"] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn scan_children_lists_one_level_and_defers_child_directories() { + let root = test_root("children"); + fs::create_dir_all(root.join("node_modules/.bin")) + .expect("bin directory should be created"); + fs::create_dir_all(root.join("node_modules/pkg")) + .expect("package directory should be created"); + fs::write(root.join("node_modules/README.md"), b"readme") + .expect("readme should be written"); + fs::write(root.join("node_modules/pkg/index.js"), b"export {}") + .expect("nested package file should be written"); + + let children = + FileTree::scan_children(&root, "node_modules/").expect("children should be listed"); + + assert_eq!( + children.paths(), + &[ + "node_modules/.bin/", + "node_modules/pkg/", + "node_modules/README.md" + ] + ); + assert_eq!( + children.deferred_paths(), + &["node_modules/.bin/", "node_modules/pkg/"] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn scan_entry_count_is_bounded() { + let mut paths = vec![String::new(); MAX_FILE_TREE_PATHS]; + + let error = push_path(&mut paths, "overflow".to_owned()) + .expect_err("entry limit should be enforced"); + + assert!( + matches!(error, FileTreeError::TooManyEntries { limit } if limit == MAX_FILE_TREE_PATHS) + ); + } + #[test] fn view_state_normalizes_expanded_paths() { let state = FileTreeViewState::new( diff --git a/core/src/state.rs b/core/src/state.rs index 16427d2a..67657ba2 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; -use crate::file_tree::{FileTree, FileTreeEntryKind, FileTreeError, FileTreeViewState}; +use crate::file_tree::{ + FileTree, FileTreeDirectory, FileTreeEntryKind, FileTreeError, FileTreeViewState, +}; use crate::tree::{ Pane, PaneId, PaneNode, SplitAxis, SplitPaneId, Tab, TabId, TabKind, Workspace, WorkspaceId, WorkspaceList, @@ -87,6 +89,17 @@ impl State { FileTree::scan_with_expanded_paths(workspace.directory(), expanded_paths) } + pub fn file_tree_children( + &self, + workspace_id: Option, + tab_id: TabId, + directory_path: &str, + ) -> Result { + let directory = self.file_tree_workspace_directory(workspace_id, tab_id)?; + + FileTree::scan_children(directory, directory_path) + } + pub fn set_file_tree_expanded_paths( &mut self, workspace_id: Option, diff --git a/desktop/src/renderer/components/tabs/file-tree.tsx b/desktop/src/renderer/components/tabs/file-tree.tsx index 36bcbdb3..4d81d566 100644 --- a/desktop/src/renderer/components/tabs/file-tree.tsx +++ b/desktop/src/renderer/components/tabs/file-tree.tsx @@ -17,6 +17,7 @@ import { createFileTreeEntry, deleteFileTreeEntries, getFileTree, + getFileTreeChildren, moveFileTreeEntries, renameFileTreeEntry, revealFileTreePath, @@ -393,6 +394,12 @@ function LoadedFileTree({ workspaceId={workspaceId} tabId={tabId} /> + {rootContextMenuPosition ? ( ()); + + useEffect(() => { + deferredPathsRef.current = new Set(snapshot.deferredPaths); + loadingPathsRef.current.clear(); + }, [snapshot.deferredPaths]); + + useEffect(() => { + let disposed = false; + + const loadDirectory = async (path: string) => { + loadingPathsRef.current.add(path); + + try { + const children = await getFileTreeChildren({ workspaceId, tabId, path }); + + if (disposed) { + return; + } + + const operations = children.paths + .filter((childPath) => !model.getItem(childPath)) + .map((childPath) => ({ type: "add" as const, path: childPath })); + + deferredPathsRef.current.delete(path); + + for (const deferredPath of children.deferredPaths) { + deferredPathsRef.current.add(deferredPath); + } + + if (operations.length > 0) { + model.batch(operations); + } + } catch (caughtError: unknown) { + if (!disposed) { + window.alert(errorMessage(caughtError)); + } + } finally { + loadingPathsRef.current.delete(path); + } + }; + + const loadExpandedDeferredDirectories = () => { + for (const path of deferredPathsRef.current) { + if (loadingPathsRef.current.has(path)) { + continue; + } + + const item = model.getItem(path); + if (isDirectoryHandle(item) && item.isExpanded()) { + void loadDirectory(path); + } + } + }; + + loadExpandedDeferredDirectories(); + + const unsubscribe = model.subscribe(loadExpandedDeferredDirectories); + + return () => { + disposed = true; + unsubscribe(); + }; + }, [model, workspaceId, tabId]); + + return null; +} + function FileTreeMessage({ message }: { message: string }) { return (
diff --git a/desktop/src/renderer/ipc/file-tree.ts b/desktop/src/renderer/ipc/file-tree.ts index b191e075..db448561 100644 --- a/desktop/src/renderer/ipc/file-tree.ts +++ b/desktop/src/renderer/ipc/file-tree.ts @@ -2,8 +2,10 @@ import type { CreateFileTreeEntryParams, DeleteFileTreeEntriesParams, DeleteFileTreeEntryParams, + FileTreeChildrenSnapshot, FileTreeResolvedPath, FileTreeSnapshot, + GetFileTreeChildrenParams, GetFileTreeParams, RenameFileTreeEntryParams, ResolveFileTreePathParams, @@ -19,6 +21,12 @@ export function getFileTree(params: GetFileTreeParams = {}): Promise { + return requestServer(DOMAIN, "getChildren", params); +} + export function setFileTreeExpandedPaths( params: SetFileTreeExpandedPathsParams, ): Promise { @@ -64,8 +72,10 @@ export type { CreateFileTreeEntryParams, DeleteFileTreeEntriesParams, DeleteFileTreeEntryParams, + FileTreeChildrenSnapshot, FileTreeResolvedPath, FileTreeSnapshot, + GetFileTreeChildrenParams, GetFileTreeParams, RenameFileTreeEntryParams, ResolveFileTreePathParams, diff --git a/desktop/src/shared/ipc/file-tree.ts b/desktop/src/shared/ipc/file-tree.ts index 0aa66f77..28d825a8 100644 --- a/desktop/src/shared/ipc/file-tree.ts +++ b/desktop/src/shared/ipc/file-tree.ts @@ -5,6 +5,12 @@ export type GetFileTreeParams = { tabId?: TabId | null; }; +export type GetFileTreeChildrenParams = { + workspaceId?: WorkspaceId | null; + tabId: TabId; + path: string; +}; + export type SetFileTreeExpandedPathsParams = { workspaceId?: WorkspaceId | null; tabId: TabId; @@ -61,4 +67,10 @@ export type FileTreeSnapshot = { root: string; paths: string[]; expandedPaths: string[]; + deferredPaths: string[]; +}; + +export type FileTreeChildrenSnapshot = { + paths: string[]; + deferredPaths: string[]; }; diff --git a/server/src/ipc/messages/file_tree.rs b/server/src/ipc/messages/file_tree.rs index a601e7a1..63f9764f 100644 --- a/server/src/ipc/messages/file_tree.rs +++ b/server/src/ipc/messages/file_tree.rs @@ -1,4 +1,4 @@ -use core::file_tree::{FileTree, FileTreeEntryKind}; +use core::file_tree::{FileTree, FileTreeDirectory, FileTreeEntryKind}; use serde::{Deserialize, Serialize}; use super::pane::WorkspaceIdParam; @@ -11,6 +11,14 @@ pub(crate) struct GetFileTreeParams { pub(crate) tab_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GetFileTreeChildrenParams { + pub(crate) workspace_id: Option, + pub(crate) tab_id: TabIdParam, + pub(crate) path: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SetFileTreeExpandedPathsParams { @@ -93,6 +101,14 @@ pub(crate) struct FileTreeSnapshot { root: String, paths: Vec, expanded_paths: Vec, + deferred_paths: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileTreeChildrenSnapshot { + paths: Vec, + deferred_paths: Vec, } #[derive(Debug, Serialize)] @@ -107,6 +123,16 @@ impl FileTreeSnapshot { root: file_tree.root().to_string_lossy().into_owned(), paths: file_tree.paths().to_vec(), expanded_paths: file_tree.expanded_paths().to_vec(), + deferred_paths: file_tree.deferred_paths().to_vec(), + } + } +} + +impl FileTreeChildrenSnapshot { + pub(crate) fn from_directory(directory: &FileTreeDirectory) -> Self { + Self { + paths: directory.paths().to_vec(), + deferred_paths: directory.deferred_paths().to_vec(), } } } diff --git a/server/src/ipc/router/file_tree.rs b/server/src/ipc/router/file_tree.rs index f6a28e88..b06d8f7e 100644 --- a/server/src/ipc/router/file_tree.rs +++ b/server/src/ipc/router/file_tree.rs @@ -3,14 +3,16 @@ use core::file_tree::FileTreeError; use super::super::messages::envelope::{RequestEnvelope, ServerMessage}; use super::super::messages::file_tree::{ CreateFileTreeEntryParams, DeleteFileTreeEntriesParams, DeleteFileTreeEntryParams, - FileTreeResolvedPath, FileTreeSnapshot, GetFileTreeParams, RenameFileTreeEntryParams, - ResolveFileTreePathParams, SetFileTreeExpandedPathsParams, TransferFileTreeEntriesParams, + FileTreeChildrenSnapshot, FileTreeResolvedPath, FileTreeSnapshot, GetFileTreeChildrenParams, + GetFileTreeParams, RenameFileTreeEntryParams, ResolveFileTreePathParams, + SetFileTreeExpandedPathsParams, TransferFileTreeEntriesParams, }; use super::{parse_params, unsupported_action}; pub(super) fn route(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match request.action.as_str() { "get" => get_file_tree(state, request), + "getChildren" => get_file_tree_children(state, request), "setExpandedPaths" => set_expanded_paths(state, request), "createEntry" => create_entry(state, request), "renameEntry" => rename_entry(state, request), @@ -40,6 +42,25 @@ fn get_file_tree(state: &mut core::State, request: &RequestEnvelope) -> ServerMe } } +fn get_file_tree_children(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { + match parse_params::(request) { + Ok(params) => match state.file_tree_children( + params.workspace_id.map(Into::into), + params.tab_id.into(), + ¶ms.path, + ) { + Ok(directory) => ServerMessage::ok( + request.id, + FileTreeChildrenSnapshot::from_directory(&directory), + ), + Err(error) => { + ServerMessage::error(request.id, file_tree_error_code(&error), error.to_string()) + } + }, + Err(response) => response, + } +} + fn set_expanded_paths(state: &mut core::State, request: &RequestEnvelope) -> ServerMessage { match parse_params::(request) { Ok(params) => { @@ -188,6 +209,7 @@ fn file_tree_error_code(error: &FileTreeError) -> &'static str { FileTreeError::WorkspaceNotFound => "file_tree.workspace_not_found", FileTreeError::TabNotFound => "file_tree.tab_not_found", FileTreeError::RootNotDirectory(_) => "file_tree.root_not_directory", + FileTreeError::TooManyEntries { .. } => "file_tree.too_many_entries", FileTreeError::InvalidPath(_) => "file_tree.invalid_path", FileTreeError::InvalidName(_) => "file_tree.invalid_name", FileTreeError::EntryNotFound(_) => "file_tree.entry_not_found", diff --git a/server/src/ipc/transport/connection.rs b/server/src/ipc/transport/connection.rs index 6b8ab8de..9f19e020 100644 --- a/server/src/ipc/transport/connection.rs +++ b/server/src/ipc/transport/connection.rs @@ -68,7 +68,7 @@ fn persistence_mode(request: &RequestEnvelope) -> PersistenceMode { match (request.domain, request.action.as_str()) { (Domain::Workspace, "list") => PersistenceMode::None, (Domain::Workspace, "activate") => PersistenceMode::ActiveWorkspace, - (Domain::FileTree, "get") => PersistenceMode::None, + (Domain::FileTree, "get" | "getChildren") => PersistenceMode::None, _ => PersistenceMode::Full, } } From e9e2bb8b350932ad8c19515e239cbcde8aa7632c Mon Sep 17 00:00:00 2001 From: Martin Etchebarne Date: Mon, 6 Jul 2026 22:02:06 -0300 Subject: [PATCH 38/98] fix: scroll overflowing pane tabs --- .../components/internal/workspace-view.tsx | 37 ++++++++++++++----- desktop/src/renderer/index.css | 11 ++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/desktop/src/renderer/components/internal/workspace-view.tsx b/desktop/src/renderer/components/internal/workspace-view.tsx index 696b3ef5..3e9f189a 100644 --- a/desktop/src/renderer/components/internal/workspace-view.tsx +++ b/desktop/src/renderer/components/internal/workspace-view.tsx @@ -1,4 +1,4 @@ -import { useState, type DragEvent } from "react"; +import { useState, type DragEvent, type WheelEvent } from "react"; import { Plus, X } from "lucide-react"; import { Button } from "@/renderer/components/ui/button"; @@ -259,14 +259,16 @@ function PaneLeaf({ } }} > - - {pane.tabs.map((tab) => ( - - ))} - + + {pane.tabs.map((tab) => ( + + ))} + +
+
+ +
+
+ + + 0} + tabParams={tabParams} + onRun={runOperation} + /> +
+
+ +
+ {hasChanges ? ( + `${change.path}:${change.staged}:${change.unstaged}`).join("|")} + changes={snapshot.changes} + paths={treePaths} + disabled={busy} + onToggleStage={toggleStagePaths} + /> + ) : ( + + )} +
+ +
+
+ + +
+